authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-02 15:20:30-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-02 15:20:30-07:00
loga04c09a190ac78e23ca63971067ff0fc2417bcca
tree46986704d13b6ab819d3aea5426049a2eaf8c15f
parente3f14058847dbd36695277aab941fefa94e21e5f
signature Signed by PGP key B5690EEEBB952194

Use HttpApi for Environment APIs & standardize authn/authz (#2858)

Co-authored-by: codex <codex@users.noreply.github.com>

102 files changed, 6822 insertions(+), 4876 deletions(-)

apps/desktop/package.json+1
...@@ -16,6 +16,7 @@...@@ -16,6 +16,7 @@
16 },16 },
17 "dependencies": {17 "dependencies": {
18 "@effect/platform-node": "catalog:",18 "@effect/platform-node": "catalog:",
19 "@t3tools/client-runtime": "workspace:*",
19 "@t3tools/contracts": "workspace:*",20 "@t3tools/contracts": "workspace:*",
20 "@t3tools/shared": "workspace:*",21 "@t3tools/shared": "workspace:*",
21 "@t3tools/ssh": "workspace:*",22 "@t3tools/ssh": "workspace:*",
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2-2
...@@ -22,7 +22,7 @@ import {...@@ -22,7 +22,7 @@ import {
22 ensureSshEnvironment,22 ensureSshEnvironment,
23 fetchSshEnvironmentDescriptor,23 fetchSshEnvironmentDescriptor,
24 fetchSshSessionState,24 fetchSshSessionState,
25 issueSshWebSocketToken,25 issueSshWebSocketTicket,
26 resolveSshPasswordPrompt,26 resolveSshPasswordPrompt,
27} from "./methods/sshEnvironment.ts";27} from "./methods/sshEnvironment.ts";
28import {28import {
...@@ -62,7 +62,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () {...@@ -62,7 +62,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () {
62 yield* ipc.handle(fetchSshEnvironmentDescriptor);62 yield* ipc.handle(fetchSshEnvironmentDescriptor);
63 yield* ipc.handle(bootstrapSshBearerSession);63 yield* ipc.handle(bootstrapSshBearerSession);
64 yield* ipc.handle(fetchSshSessionState);64 yield* ipc.handle(fetchSshSessionState);
65 yield* ipc.handle(issueSshWebSocketToken);65 yield* ipc.handle(issueSshWebSocketTicket);
66 yield* ipc.handle(resolveSshPasswordPrompt);66 yield* ipc.handle(resolveSshPasswordPrompt);
6767
68 yield* ipc.handle(getServerExposureState);68 yield* ipc.handle(getServerExposureState);
apps/desktop/src/ipc/methods/sshEnvironment.test.ts created+115
...@@ -0,0 +1,115 @@
1import { assert, describe, it } from "@effect/vitest";
2import { SshHttpBridgeError } from "@t3tools/ssh/errors";
3import * as Cause from "effect/Cause";
4import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
9
10import {
11 DesktopSshEnvironmentRequestError,
12 fetchSshEnvironmentDescriptor,
13} from "./sshEnvironment.ts";
14
15function jsonResponse(request: HttpClientRequest.HttpClientRequest, body: unknown, status = 200) {
16 return HttpClientResponse.fromWeb(
17 request,
18 new Response(JSON.stringify(body), {
19 status,
20 headers: { "content-type": "application/json" },
21 }),
22 );
23}
24
25function makeHttpClientLayer(
26 handler: (
27 request: HttpClientRequest.HttpClientRequest,
28 ) => Effect.Effect<HttpClientResponse.HttpClientResponse, never>,
29) {
30 return Layer.succeed(
31 HttpClient.HttpClient,
32 HttpClient.make((request) => handler(request)),
33 );
34}
35
36describe("SSH environment IPC", () => {
37 it.effect("fetches and decodes the remote environment descriptor", () => {
38 const requestUrls: string[] = [];
39 const layer = makeHttpClientLayer((request) =>
40 Effect.sync(() => {
41 requestUrls.push(request.url);
42 return jsonResponse(request, {
43 environmentId: "remote-env",
44 label: "Remote Devbox",
45 platform: { os: "linux", arch: "x64" },
46 serverVersion: "1.2.3",
47 capabilities: { repositoryIdentity: true },
48 });
49 }),
50 );
51
52 return Effect.gen(function* () {
53 const descriptor = yield* fetchSshEnvironmentDescriptor.handler({
54 httpBaseUrl: "http://127.0.0.1:41773/",
55 });
56
57 assert.deepEqual(descriptor, {
58 environmentId: "remote-env",
59 label: "Remote Devbox",
60 platform: { os: "linux", arch: "x64" },
61 serverVersion: "1.2.3",
62 capabilities: { repositoryIdentity: true },
63 });
64 assert.deepEqual(requestUrls, ["http://127.0.0.1:41773/.well-known/t3/environment"]);
65 }).pipe(Effect.provide(layer));
66 });
67
68 it.effect("wraps schema decode failures in a typed request error", () => {
69 const layer = makeHttpClientLayer((request) =>
70 Effect.succeed(jsonResponse(request, { environmentId: "remote-env" })),
71 );
72
73 return Effect.gen(function* () {
74 const exit = yield* Effect.exit(
75 fetchSshEnvironmentDescriptor.handler({
76 httpBaseUrl: "http://127.0.0.1:41773/",
77 }),
78 );
79 assert(Exit.isFailure(exit));
80 const failure = Cause.findErrorOption(exit.cause);
81 assert(Option.isSome(failure));
82 const error = failure.value;
83
84 assert.instanceOf(error, DesktopSshEnvironmentRequestError);
85 assert.equal(error.operation, "fetch-environment-descriptor");
86 assert.equal(error.cause instanceof SshHttpBridgeError, false);
87 }).pipe(Effect.provide(layer));
88 });
89
90 it.effect("rejects non-loopback HTTP endpoints before issuing a request", () => {
91 let requestCount = 0;
92 const layer = makeHttpClientLayer((request) =>
93 Effect.sync(() => {
94 requestCount += 1;
95 return jsonResponse(request, {});
96 }),
97 );
98
99 return Effect.gen(function* () {
100 const exit = yield* Effect.exit(
101 fetchSshEnvironmentDescriptor.handler({
102 httpBaseUrl: "http://remote.example.com:41773/",
103 }),
104 );
105 assert(Exit.isFailure(exit));
106 const failure = Cause.findErrorOption(exit.cause);
107 assert(Option.isSome(failure));
108 const error = failure.value;
109
110 assert.instanceOf(error, DesktopSshEnvironmentRequestError);
111 assert.instanceOf(error.cause, SshHttpBridgeError);
112 assert.equal(requestCount, 0);
113 }).pipe(Effect.provide(layer));
114 });
115});
apps/desktop/src/ipc/methods/sshEnvironment.ts+63-15
...@@ -1,3 +1,9 @@...@@ -1,3 +1,9 @@
1import {
2 bootstrapRemoteBearerSession,
3 fetchRemoteEnvironmentDescriptor,
4 fetchRemoteSessionState,
5 issueRemoteWebSocketTicket,
6} from "@t3tools/client-runtime";
1import {7import {
2 DesktopDiscoveredSshHostSchema,8 DesktopDiscoveredSshHostSchema,
3 DesktopSshBearerBootstrapInputSchema,9 DesktopSshBearerBootstrapInputSchema,
...@@ -9,10 +15,12 @@ import {...@@ -9,10 +15,12 @@ import {
9 DesktopSshPasswordPromptCancelledType,15 DesktopSshPasswordPromptCancelledType,
10 DesktopSshPasswordPromptResolutionInputSchema,16 DesktopSshPasswordPromptResolutionInputSchema,
11 ExecutionEnvironmentDescriptor,17 ExecutionEnvironmentDescriptor,
12 AuthBearerBootstrapResult,18 AuthAccessTokenResult,
13 AuthSessionState,19 AuthSessionState,
14 AuthWebSocketTokenResult,20 AuthWebSocketTicketResult,
15} from "@t3tools/contracts";21} from "@t3tools/contracts";
22import { resolveLoopbackSshHttpBaseUrl } from "@t3tools/ssh/tunnel";
23import * as Data from "effect/Data";
16import * as Effect from "effect/Effect";24import * as Effect from "effect/Effect";
17import * as Schema from "effect/Schema";25import * as Schema from "effect/Schema";
1826
...@@ -20,7 +28,34 @@ import * as IpcChannels from "../channels.ts";...@@ -20,7 +28,34 @@ import * as IpcChannels from "../channels.ts";
20import { makeIpcMethod } from "../DesktopIpc.ts";28import { makeIpcMethod } from "../DesktopIpc.ts";
21import * as DesktopSshEnvironment from "../../ssh/DesktopSshEnvironment.ts";29import * as DesktopSshEnvironment from "../../ssh/DesktopSshEnvironment.ts";
22import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts.ts";30import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts.ts";
23import * as DesktopSshRemoteApi from "../../ssh/DesktopSshRemoteApi.ts";31
32type DesktopSshEnvironmentRequestOperation =
33 | "fetch-environment-descriptor"
34 | "bootstrap-bearer-session"
35 | "fetch-session-state"
36 | "issue-websocket-ticket";
37
38export class DesktopSshEnvironmentRequestError extends Data.TaggedError(
39 "DesktopSshEnvironmentRequestError",
40)<{
41 readonly operation: DesktopSshEnvironmentRequestOperation;
42 readonly cause: unknown;
43}> {
44 override get message() {
45 return `SSH remote API request failed during ${this.operation}.`;
46 }
47}
48
49const withLoopbackSshApi =
50 <A, E, R>(
51 operation: DesktopSshEnvironmentRequestOperation,
52 use: (httpBaseUrl: string) => Effect.Effect<A, E, R>,
53 ) =>
54 (httpBaseUrl: string): Effect.Effect<A, DesktopSshEnvironmentRequestError, R> =>
55 resolveLoopbackSshHttpBaseUrl(httpBaseUrl).pipe(
56 Effect.flatMap(use),
57 Effect.mapError((cause) => new DesktopSshEnvironmentRequestError({ operation, cause })),
58 );
2459
25export const discoverSshHosts = makeIpcMethod({60export const discoverSshHosts = makeIpcMethod({
26 channel: IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL,61 channel: IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL,
...@@ -69,21 +104,26 @@ export const fetchSshEnvironmentDescriptor = makeIpcMethod({...@@ -69,21 +104,26 @@ export const fetchSshEnvironmentDescriptor = makeIpcMethod({
69 payload: DesktopSshHttpBaseUrlInputSchema,104 payload: DesktopSshHttpBaseUrlInputSchema,
70 result: ExecutionEnvironmentDescriptor,105 result: ExecutionEnvironmentDescriptor,
71 handler: Effect.fn("desktop.ipc.sshEnvironment.fetchDescriptor")(function* ({ httpBaseUrl }) {106 handler: Effect.fn("desktop.ipc.sshEnvironment.fetchDescriptor")(function* ({ httpBaseUrl }) {
72 const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi;107 return yield* withLoopbackSshApi("fetch-environment-descriptor", (resolvedHttpBaseUrl) =>
73 return yield* remoteApi.fetchEnvironmentDescriptor({ httpBaseUrl });108 fetchRemoteEnvironmentDescriptor({ httpBaseUrl: resolvedHttpBaseUrl }),
109 )(httpBaseUrl);
74 }),110 }),
75});111});
76112
77export const bootstrapSshBearerSession = makeIpcMethod({113export const bootstrapSshBearerSession = makeIpcMethod({
78 channel: IpcChannels.BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL,114 channel: IpcChannels.BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL,
79 payload: DesktopSshBearerBootstrapInputSchema,115 payload: DesktopSshBearerBootstrapInputSchema,
80 result: AuthBearerBootstrapResult,116 result: AuthAccessTokenResult,
81 handler: Effect.fn("desktop.ipc.sshEnvironment.bootstrapBearerSession")(function* ({117 handler: Effect.fn("desktop.ipc.sshEnvironment.bootstrapBearerSession")(function* ({
82 httpBaseUrl,118 httpBaseUrl,
83 credential,119 credential,
84 }) {120 }) {
85 const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi;121 return yield* withLoopbackSshApi("bootstrap-bearer-session", (resolvedHttpBaseUrl) =>
86 return yield* remoteApi.bootstrapBearerSession({ httpBaseUrl, credential });122 bootstrapRemoteBearerSession({
123 httpBaseUrl: resolvedHttpBaseUrl,
124 credential,
125 }),
126 )(httpBaseUrl);
87 }),127 }),
88});128});
89129
...@@ -95,21 +135,29 @@ export const fetchSshSessionState = makeIpcMethod({...@@ -95,21 +135,29 @@ export const fetchSshSessionState = makeIpcMethod({
95 httpBaseUrl,135 httpBaseUrl,
96 bearerToken,136 bearerToken,
97 }) {137 }) {
98 const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi;138 return yield* withLoopbackSshApi("fetch-session-state", (resolvedHttpBaseUrl) =>
99 return yield* remoteApi.fetchSessionState({ httpBaseUrl, bearerToken });139 fetchRemoteSessionState({
140 httpBaseUrl: resolvedHttpBaseUrl,
141 bearerToken,
142 }),
143 )(httpBaseUrl);
100 }),144 }),
101});145});
102146
103export const issueSshWebSocketToken = makeIpcMethod({147export const issueSshWebSocketTicket = makeIpcMethod({
104 channel: IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL,148 channel: IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL,
105 payload: DesktopSshBearerRequestInputSchema,149 payload: DesktopSshBearerRequestInputSchema,
106 result: AuthWebSocketTokenResult,150 result: AuthWebSocketTicketResult,
107 handler: Effect.fn("desktop.ipc.sshEnvironment.issueWebSocketToken")(function* ({151 handler: Effect.fn("desktop.ipc.sshEnvironment.issueWebSocketTicket")(function* ({
108 httpBaseUrl,152 httpBaseUrl,
109 bearerToken,153 bearerToken,
110 }) {154 }) {
111 const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi;155 return yield* withLoopbackSshApi("issue-websocket-ticket", (resolvedHttpBaseUrl) =>
112 return yield* remoteApi.issueWebSocketToken({ httpBaseUrl, bearerToken });156 issueRemoteWebSocketTicket({
157 httpBaseUrl: resolvedHttpBaseUrl,
158 bearerToken,
159 }),
160 )(httpBaseUrl);
113 }),161 }),
114});162});
115163
apps/desktop/src/main.ts+1-2
...@@ -40,7 +40,6 @@ import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts";...@@ -40,7 +40,6 @@ import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts";
40import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts";40import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts";
41import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts";41import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts";
42import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts";42import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts";
43import * as DesktopSshRemoteApi from "./ssh/DesktopSshRemoteApi.ts";
44import * as DesktopState from "./app/DesktopState.ts";43import * as DesktopState from "./app/DesktopState.ts";
45import * as DesktopUpdates from "./updates/DesktopUpdates.ts";44import * as DesktopUpdates from "./updates/DesktopUpdates.ts";
46import * as DesktopWindow from "./window/DesktopWindow.ts";45import * as DesktopWindow from "./window/DesktopWindow.ts";
...@@ -116,7 +115,7 @@ const desktopFoundationLayer = Layer.mergeAll(...@@ -116,7 +115,7 @@ const desktopFoundationLayer = Layer.mergeAll(
116 DesktopObservability.layer,115 DesktopObservability.layer,
117).pipe(Layer.provideMerge(desktopEnvironmentLayer));116).pipe(Layer.provideMerge(desktopEnvironmentLayer));
118117
119const desktopSshLayer = Layer.mergeAll(desktopSshEnvironmentLayer, DesktopSshRemoteApi.layer).pipe(118const desktopSshLayer = desktopSshEnvironmentLayer.pipe(
120 Layer.provideMerge(DesktopSshPasswordPrompts.layer()),119 Layer.provideMerge(DesktopSshPasswordPrompts.layer()),
121);120);
122121
apps/desktop/src/preload.ts+1-1
...@@ -66,7 +66,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -66,7 +66,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
66 }),66 }),
67 fetchSshSessionState: (httpBaseUrl, bearerToken) =>67 fetchSshSessionState: (httpBaseUrl, bearerToken) =>
68 ipcRenderer.invoke(IpcChannels.FETCH_SSH_SESSION_STATE_CHANNEL, { httpBaseUrl, bearerToken }),68 ipcRenderer.invoke(IpcChannels.FETCH_SSH_SESSION_STATE_CHANNEL, { httpBaseUrl, bearerToken }),
69 issueSshWebSocketToken: (httpBaseUrl, bearerToken) =>69 issueSshWebSocketTicket: (httpBaseUrl, bearerToken) =>
70 ipcRenderer.invoke(IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, { httpBaseUrl, bearerToken }),70 ipcRenderer.invoke(IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, { httpBaseUrl, bearerToken }),
71 onSshPasswordPrompt: (listener) => {71 onSshPasswordPrompt: (listener) => {
72 const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => {72 const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => {
apps/desktop/src/ssh/DesktopSshRemoteApi.test.ts deleted-79
...@@ -1,79 +0,0 @@
1import { assert, describe, it } from "@effect/vitest";
2import { SshHttpBridgeError } from "@t3tools/ssh/errors";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
6
7import * as DesktopSshRemoteApi from "./DesktopSshRemoteApi.ts";
8
9function jsonResponse(request: HttpClientRequest.HttpClientRequest, body: unknown, status = 200) {
10 return HttpClientResponse.fromWeb(
11 request,
12 new Response(JSON.stringify(body), {
13 status,
14 headers: { "content-type": "application/json" },
15 }),
16 );
17}
18
19function makeLayer(
20 handler: (
21 request: HttpClientRequest.HttpClientRequest,
22 ) => Effect.Effect<HttpClientResponse.HttpClientResponse>,
23) {
24 return DesktopSshRemoteApi.layer.pipe(
25 Layer.provide(
26 Layer.succeed(
27 HttpClient.HttpClient,
28 HttpClient.make((request) => handler(request)),
29 ),
30 ),
31 );
32}
33
34describe("DesktopSshRemoteApi", () => {
35 it.effect("fetches and decodes the remote environment descriptor", () => {
36 const requestUrls: string[] = [];
37 const layer = makeLayer((request) =>
38 Effect.sync(() => {
39 requestUrls.push(request.url);
40 return jsonResponse(request, {
41 environmentId: "remote-env",
42 label: "Remote Devbox",
43 platform: { os: "linux", arch: "x64" },
44 serverVersion: "1.2.3",
45 capabilities: { repositoryIdentity: true },
46 });
47 }),
48 );
49
50 return Effect.gen(function* () {
51 const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi;
52 const descriptor = yield* remoteApi.fetchEnvironmentDescriptor({
53 httpBaseUrl: "http://127.0.0.1:41773/",
54 });
55
56 assert.equal(descriptor.label, "Remote Devbox");
57 assert.deepEqual(requestUrls, ["http://127.0.0.1:41773/.well-known/t3/environment"]);
58 }).pipe(Effect.provide(layer));
59 });
60
61 it.effect("wraps schema decode failures in a typed remote api error", () => {
62 const layer = makeLayer((request) =>
63 Effect.succeed(jsonResponse(request, { environmentId: "remote-env" })),
64 );
65
66 return Effect.gen(function* () {
67 const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi;
68 const error = yield* remoteApi
69 .fetchEnvironmentDescriptor({
70 httpBaseUrl: "http://127.0.0.1:41773/",
71 })
72 .pipe(Effect.flip);
73
74 assert.instanceOf(error, DesktopSshRemoteApi.DesktopSshRemoteApiError);
75 assert.equal(error.operation, "fetch-environment-descriptor");
76 assert.equal(error.cause instanceof SshHttpBridgeError, false);
77 }).pipe(Effect.provide(layer));
78 });
79});
apps/desktop/src/ssh/DesktopSshRemoteApi.ts deleted-124
...@@ -1,124 +0,0 @@
1import {
2 AuthBearerBootstrapResult,
3 AuthSessionState,
4 AuthWebSocketTokenResult,
5 type AuthBearerBootstrapResult as AuthBearerBootstrapResultType,
6 type AuthSessionState as AuthSessionStateType,
7 type AuthWebSocketTokenResult as AuthWebSocketTokenResultType,
8 ExecutionEnvironmentDescriptor,
9 type ExecutionEnvironmentDescriptor as ExecutionEnvironmentDescriptorType,
10} from "@t3tools/contracts";
11import { SshHttpBridgeError } from "@t3tools/ssh/errors";
12import { fetchLoopbackSshJson } from "@t3tools/ssh/tunnel";
13import * as Context from "effect/Context";
14import * as Data from "effect/Data";
15import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";
17import * as Schema from "effect/Schema";
18import { HttpClient } from "effect/unstable/http";
19
20export type DesktopSshRemoteApiOperation =
21 | "fetch-environment-descriptor"
22 | "bootstrap-bearer-session"
23 | "fetch-session-state"
24 | "issue-websocket-token";
25
26export class DesktopSshRemoteApiError extends Data.TaggedError("DesktopSshRemoteApiError")<{
27 readonly operation: DesktopSshRemoteApiOperation;
28 readonly cause: SshHttpBridgeError | Schema.SchemaError;
29}> {
30 override get message() {
31 return `SSH remote API request failed during ${this.operation}.`;
32 }
33}
34
35export interface DesktopSshRemoteApiShape {
36 readonly fetchEnvironmentDescriptor: (input: {
37 readonly httpBaseUrl: string;
38 }) => Effect.Effect<ExecutionEnvironmentDescriptorType, DesktopSshRemoteApiError>;
39 readonly bootstrapBearerSession: (input: {
40 readonly httpBaseUrl: string;
41 readonly credential: string;
42 }) => Effect.Effect<AuthBearerBootstrapResultType, DesktopSshRemoteApiError>;
43 readonly fetchSessionState: (input: {
44 readonly httpBaseUrl: string;
45 readonly bearerToken: string;
46 }) => Effect.Effect<AuthSessionStateType, DesktopSshRemoteApiError>;
47 readonly issueWebSocketToken: (input: {
48 readonly httpBaseUrl: string;
49 readonly bearerToken: string;
50 }) => Effect.Effect<AuthWebSocketTokenResultType, DesktopSshRemoteApiError>;
51}
52
53export class DesktopSshRemoteApi extends Context.Service<
54 DesktopSshRemoteApi,
55 DesktopSshRemoteApiShape
56>()("@t3tools/desktop/ssh/DesktopSshRemoteApi") {}
57
58const decodeExecutionEnvironmentDescriptor = Schema.decodeUnknownEffect(
59 ExecutionEnvironmentDescriptor,
60);
61const decodeAuthBearerBootstrapResult = Schema.decodeUnknownEffect(AuthBearerBootstrapResult);
62const decodeAuthSessionState = Schema.decodeUnknownEffect(AuthSessionState);
63const decodeAuthWebSocketTokenResult = Schema.decodeUnknownEffect(AuthWebSocketTokenResult);
64
65const mapError =
66 (operation: DesktopSshRemoteApiOperation) =>
67 (cause: SshHttpBridgeError | Schema.SchemaError): DesktopSshRemoteApiError =>
68 new DesktopSshRemoteApiError({ operation, cause });
69
70const make = Effect.gen(function* () {
71 const httpClient = yield* HttpClient.HttpClient;
72 const provideHttpClient = <A, E>(effect: Effect.Effect<A, E, HttpClient.HttpClient>) =>
73 effect.pipe(Effect.provideService(HttpClient.HttpClient, httpClient));
74
75 return DesktopSshRemoteApi.of({
76 fetchEnvironmentDescriptor: ({ httpBaseUrl }) =>
77 fetchLoopbackSshJson<unknown>({
78 httpBaseUrl,
79 pathname: "/.well-known/t3/environment",
80 }).pipe(
81 Effect.flatMap(decodeExecutionEnvironmentDescriptor),
82 Effect.mapError(mapError("fetch-environment-descriptor")),
83 provideHttpClient,
84 Effect.withSpan("desktop.sshRemoteApi.fetchEnvironmentDescriptor"),
85 ),
86 bootstrapBearerSession: ({ httpBaseUrl, credential }) =>
87 fetchLoopbackSshJson<unknown>({
88 httpBaseUrl,
89 pathname: "/api/auth/bootstrap/bearer",
90 method: "POST",
91 body: { credential },
92 }).pipe(
93 Effect.flatMap(decodeAuthBearerBootstrapResult),
94 Effect.mapError(mapError("bootstrap-bearer-session")),
95 provideHttpClient,
96 Effect.withSpan("desktop.sshRemoteApi.bootstrapBearerSession"),
97 ),
98 fetchSessionState: ({ httpBaseUrl, bearerToken }) =>
99 fetchLoopbackSshJson<unknown>({
100 httpBaseUrl,
101 pathname: "/api/auth/session",
102 bearerToken,
103 }).pipe(
104 Effect.flatMap(decodeAuthSessionState),
105 Effect.mapError(mapError("fetch-session-state")),
106 provideHttpClient,
107 Effect.withSpan("desktop.sshRemoteApi.fetchSessionState"),
108 ),
109 issueWebSocketToken: ({ httpBaseUrl, bearerToken }) =>
110 fetchLoopbackSshJson<unknown>({
111 httpBaseUrl,
112 pathname: "/api/auth/ws-token",
113 method: "POST",
114 bearerToken,
115 }).pipe(
116 Effect.flatMap(decodeAuthWebSocketTokenResult),
117 Effect.mapError(mapError("issue-websocket-token")),
118 provideHttpClient,
119 Effect.withSpan("desktop.sshRemoteApi.issueWebSocketToken"),
120 ),
121 });
122});
123
124export const layer = Layer.effect(DesktopSshRemoteApi, make);
apps/mobile/src/lib/connection.test.ts+15-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { describe, expect, it, vi } from "vitest";1import { describe, expect, it, vi } from "vitest";
22
3import { redactPairingCredential } from "./connection";3import { mobileAuthClientMetadata, redactPairingCredential } from "./connection";
44
5vi.mock("./runtime", () => ({5vi.mock("./runtime", () => ({
6 mobileRemoteHttpRuntime: {6 mobileRemoteHttpRuntime: {
...@@ -8,7 +8,21 @@ vi.mock("./runtime", () => ({...@@ -8,7 +8,21 @@ vi.mock("./runtime", () => ({
8 },8 },
9}));9}));
1010
11vi.mock("react-native", () => ({
12 Platform: {
13 OS: "ios",
14 },
15}));
16
11describe("mobile remote connection records", () => {17describe("mobile remote connection records", () => {
18 it("identifies mobile token exchanges for authorized-client presentation", () => {
19 expect(mobileAuthClientMetadata()).toEqual({
20 label: "T3 Code Mobile",
21 deviceType: "mobile",
22 os: "iOS",
23 });
24 });
25
12 it("removes one-time bootstrap credentials before persisting pairing URLs", () => {26 it("removes one-time bootstrap credentials before persisting pairing URLs", () => {
13 expect(redactPairingCredential("https://desktop.example/#token=bootstrap-token")).toBe(27 expect(redactPairingCredential("https://desktop.example/#token=bootstrap-token")).toBe(
14 "https://desktop.example/",28 "https://desktop.example/",
apps/mobile/src/lib/connection.ts+12-2
...@@ -1,9 +1,10 @@...@@ -1,9 +1,10 @@
1import { EnvironmentId } from "@t3tools/contracts";1import { type AuthClientPresentationMetadata, EnvironmentId } from "@t3tools/contracts";
2import {2import {
3 bootstrapRemoteBearerSession,3 bootstrapRemoteBearerSession,
4 fetchRemoteEnvironmentDescriptor,4 fetchRemoteEnvironmentDescriptor,
5} from "@t3tools/client-runtime";5} from "@t3tools/client-runtime";
6import { resolveRemotePairingTarget, stripPairingTokenFromUrl } from "@t3tools/shared/remote";6import { resolveRemotePairingTarget, stripPairingTokenFromUrl } from "@t3tools/shared/remote";
7import { Platform } from "react-native";
7import { mobileRemoteHttpRuntime } from "./runtime";8import { mobileRemoteHttpRuntime } from "./runtime";
89
9export interface RemoteConnectionInput {10export interface RemoteConnectionInput {
...@@ -36,6 +37,14 @@ export function redactPairingCredential(pairingUrl: string): string {...@@ -36,6 +37,14 @@ export function redactPairingCredential(pairingUrl: string): string {
36 }37 }
37}38}
3839
40export function mobileAuthClientMetadata(): AuthClientPresentationMetadata {
41 return {
42 label: "T3 Code Mobile",
43 deviceType: "mobile",
44 ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}),
45 };
46}
47
39export async function bootstrapRemoteConnection(48export async function bootstrapRemoteConnection(
40 input: RemoteConnectionInput,49 input: RemoteConnectionInput,
41): Promise<SavedRemoteConnection> {50): Promise<SavedRemoteConnection> {
...@@ -53,6 +62,7 @@ export async function bootstrapRemoteConnection(...@@ -53,6 +62,7 @@ export async function bootstrapRemoteConnection(
53 bootstrapRemoteBearerSession({62 bootstrapRemoteBearerSession({
54 httpBaseUrl: target.httpBaseUrl,63 httpBaseUrl: target.httpBaseUrl,
55 credential: target.credential,64 credential: target.credential,
65 clientMetadata: mobileAuthClientMetadata(),
56 }),66 }),
57 );67 );
5868
...@@ -63,6 +73,6 @@ export async function bootstrapRemoteConnection(...@@ -63,6 +73,6 @@ export async function bootstrapRemoteConnection(
63 displayUrl: target.httpBaseUrl,73 displayUrl: target.httpBaseUrl,
64 httpBaseUrl: target.httpBaseUrl,74 httpBaseUrl: target.httpBaseUrl,
65 wsBaseUrl: target.wsBaseUrl,75 wsBaseUrl: target.wsBaseUrl,
66 bearerToken: bootstrap.sessionToken,76 bearerToken: bootstrap.access_token,
67 };77 };
68}78}
apps/server/src/auth/EnvironmentAuth.test.ts created+261
...@@ -0,0 +1,261 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { AuthAdministrativeScopes } from "@t3tools/contracts";
3import { expect, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6
7import type { ServerConfigShape } from "../config.ts";
8import { ServerConfig } from "../config.ts";
9import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
10import * as PairingGrantStore from "./PairingGrantStore.ts";
11import * as EnvironmentAuth from "./EnvironmentAuth.ts";
12
13import * as ServerSecretStore from "./ServerSecretStore.ts";
14
15const makeServerConfigLayer = (overrides?: Partial<ServerConfigShape>) =>
16 Layer.effect(
17 ServerConfig,
18 Effect.gen(function* () {
19 const config = yield* ServerConfig;
20 return {
21 ...config,
22 ...overrides,
23 } satisfies ServerConfigShape;
24 }),
25 ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-server-test-" })));
26
27const makeEnvironmentAuthLayer = (overrides?: Partial<ServerConfigShape>) =>
28 EnvironmentAuth.layer.pipe(
29 Layer.provide(SqlitePersistenceMemory),
30 Layer.provide(ServerSecretStore.layer),
31 Layer.provide(makeServerConfigLayer(overrides)),
32 );
33
34const makeCookieRequest = (
35 sessionToken: string,
36): Parameters<EnvironmentAuth.EnvironmentAuthShape["authenticateHttpRequest"]>[0] =>
37 ({
38 cookies: {
39 t3_session: sessionToken,
40 },
41 headers: {},
42 }) as unknown as Parameters<EnvironmentAuth.EnvironmentAuthShape["authenticateHttpRequest"]>[0];
43
44const requestMetadata = {
45 deviceType: "desktop" as const,
46 os: "macOS",
47 browser: "Chrome",
48 ipAddress: "192.168.1.23",
49};
50
51it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
52 it.effect("classifies invalid bootstrap credential failures for the HTTP boundary", () =>
53 Effect.sync(() => {
54 const error = EnvironmentAuth.toBootstrapExchangeError(
55 new PairingGrantStore.BootstrapCredentialInvalidError({
56 message: "Unknown bootstrap credential.",
57 }),
58 );
59
60 expect(error._tag).toBe("ServerAuthInvalidCredentialError");
61 if (error._tag === "ServerAuthInvalidCredentialError") {
62 expect(error.reason).toBe("invalid_credential");
63 }
64 }),
65 );
66
67 it.effect("maps unexpected bootstrap failures to 500", () =>
68 Effect.sync(() => {
69 const error = EnvironmentAuth.toBootstrapExchangeError(
70 new PairingGrantStore.BootstrapCredentialInternalError({
71 message: "Failed to consume bootstrap credential.",
72 cause: new Error("sqlite is unavailable"),
73 }),
74 );
75
76 expect(error._tag).toBe("ServerAuthInternalError");
77 expect(error.message).toBe("Failed to validate bootstrap credential.");
78 }),
79 );
80
81 it.effect("issues standard pairing credentials by default", () =>
82 Effect.gen(function* () {
83 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
84
85 const pairingCredential = yield* serverAuth.issuePairingCredential();
86 const exchanged = yield* serverAuth.createBrowserSession(
87 pairingCredential.credential,
88 requestMetadata,
89 );
90 const verified = yield* serverAuth.authenticateHttpRequest(
91 makeCookieRequest(exchanged.sessionToken),
92 );
93
94 expect(verified.sessionId.length).toBeGreaterThan(0);
95 expect(verified.scopes).toEqual([
96 "orchestration:read",
97 "orchestration:operate",
98 "terminal:operate",
99 "review:write",
100 "relay:read",
101 ]);
102 expect(verified.subject).toBe("one-time-token");
103 }).pipe(Effect.provide(makeEnvironmentAuthLayer())),
104 );
105
106 it.effect("does not exchange ordinary pairing grants for administrative access tokens", () =>
107 Effect.gen(function* () {
108 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
109 const pairingCredential = yield* serverAuth.issuePairingCredential();
110
111 const error = yield* serverAuth
112 .exchangeBootstrapCredentialForAccessToken(
113 pairingCredential.credential,
114 ["orchestration:read", "access:write"],
115 requestMetadata,
116 )
117 .pipe(Effect.flip);
118
119 expect(error._tag).toBe("ServerAuthInvalidRequestError");
120 if (error._tag === "ServerAuthInvalidRequestError") {
121 expect(error.reason).toBe("scope_not_granted");
122 }
123 }).pipe(Effect.provide(makeEnvironmentAuthLayer())),
124 );
125
126 it.effect("inherits a constrained pairing grant when token exchange omits scope", () =>
127 Effect.gen(function* () {
128 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
129 const pairingCredential = yield* serverAuth.issuePairingCredential({
130 scopes: ["orchestration:read"],
131 });
132
133 const token = yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
134 pairingCredential.credential,
135 undefined,
136 requestMetadata,
137 );
138
139 expect(token.scope).toBe("orchestration:read");
140 }).pipe(Effect.provide(makeEnvironmentAuthLayer())),
141 );
142
143 it.effect("keeps user-issued administrative pairing links manageable", () =>
144 Effect.gen(function* () {
145 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
146 const pairingCredential = yield* serverAuth.issuePairingCredential({
147 scopes: AuthAdministrativeScopes,
148 });
149 const listedPairingLinks = yield* serverAuth.listPairingLinks();
150
151 expect(
152 listedPairingLinks.find((pairingLink) => pairingLink.id === pairingCredential.id)?.subject,
153 ).toBe("one-time-token");
154 }).pipe(Effect.provide(makeEnvironmentAuthLayer())),
155 );
156
157 it.effect("issues startup pairing URLs that bootstrap administrative sessions", () =>
158 Effect.gen(function* () {
159 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
160
161 const pairingUrl = yield* serverAuth.issueStartupPairingUrl("http://127.0.0.1:3773");
162 const token = new URLSearchParams(new URL(pairingUrl).hash.slice(1)).get("token");
163 const listedPairingLinks = yield* serverAuth.listPairingLinks();
164 expect(token).toBeTruthy();
165 expect(
166 listedPairingLinks.some(
167 (pairingLink) => pairingLink.subject === "administrative-bootstrap",
168 ),
169 ).toBe(false);
170
171 const exchanged = yield* serverAuth.createBrowserSession(token ?? "", requestMetadata);
172 const verified = yield* serverAuth.authenticateHttpRequest(
173 makeCookieRequest(exchanged.sessionToken),
174 );
175
176 expect(verified.scopes).toEqual([
177 "orchestration:read",
178 "orchestration:operate",
179 "terminal:operate",
180 "review:write",
181 "relay:read",
182 "access:read",
183 "access:write",
184 "relay:write",
185 ]);
186 expect(verified.subject).toBe("administrative-bootstrap");
187 }).pipe(Effect.provide(makeEnvironmentAuthLayer())),
188 );
189
190 it.effect(
191 "lists pairing links and revokes other sessions while keeping the administrative session",
192 () =>
193 Effect.gen(function* () {
194 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
195
196 const administrativeExchange = yield* serverAuth.createBrowserSession(
197 "desktop-bootstrap-token",
198 requestMetadata,
199 );
200 const administrativeSession = yield* serverAuth.authenticateHttpRequest(
201 makeCookieRequest(administrativeExchange.sessionToken),
202 );
203 const pairingCredential = yield* serverAuth.issuePairingCredential({
204 label: "Julius iPhone",
205 });
206 const listedPairingLinks = yield* serverAuth.listPairingLinks();
207 const clientExchange = yield* serverAuth.createBrowserSession(
208 pairingCredential.credential,
209 {
210 ...requestMetadata,
211 deviceType: "mobile",
212 os: "iOS",
213 browser: "Safari",
214 ipAddress: "192.168.1.88",
215 },
216 );
217 const clientSession = yield* serverAuth.authenticateHttpRequest(
218 makeCookieRequest(clientExchange.sessionToken),
219 );
220 const clientsBeforeRevoke = yield* serverAuth.listClientSessions(
221 administrativeSession.sessionId,
222 );
223 const revokedCount = yield* serverAuth.revokeOtherClientSessions(
224 administrativeSession.sessionId,
225 );
226 const clientsAfterRevoke = yield* serverAuth.listClientSessions(
227 administrativeSession.sessionId,
228 );
229
230 expect(listedPairingLinks.map((entry) => entry.id)).toContain(pairingCredential.id);
231 expect(listedPairingLinks.find((entry) => entry.id === pairingCredential.id)?.label).toBe(
232 "Julius iPhone",
233 );
234 expect(clientsBeforeRevoke).toHaveLength(2);
235 expect(
236 clientsBeforeRevoke.find((entry) => entry.sessionId === administrativeSession.sessionId)
237 ?.current,
238 ).toBe(true);
239 expect(
240 clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.current,
241 ).toBe(false);
242 expect(
243 clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.client
244 .label,
245 ).toBe("Julius iPhone");
246 expect(
247 clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.client
248 .deviceType,
249 ).toBe("mobile");
250 expect(revokedCount).toBe(1);
251 expect(clientsAfterRevoke).toHaveLength(1);
252 expect(clientsAfterRevoke[0]?.sessionId).toBe(administrativeSession.sessionId);
253 }).pipe(
254 Effect.provide(
255 makeEnvironmentAuthLayer({
256 desktopBootstrapToken: "desktop-bootstrap-token",
257 }),
258 ),
259 ),
260 );
261});
apps/server/src/auth/EnvironmentAuth.ts created+662
...@@ -0,0 +1,662 @@
1import {
2 AuthAccessTokenType,
3 AuthAccessWriteScope,
4 AuthAdministrativeScopes,
5 AuthStandardClientScopes,
6 type AuthAccessTokenResult,
7 type AuthBrowserSessionResult,
8 type AuthClientMetadata,
9 type AuthClientSession,
10 type AuthCreatePairingCredentialInput,
11 type AuthEnvironmentScope,
12 type AuthPairingLink,
13 type AuthPairingCredentialResult,
14 type AuthSessionId,
15 type AuthSessionState,
16 type ServerAuthDescriptor,
17 type ServerAuthSessionMethod,
18 type AuthWebSocketTicketResult,
19} from "@t3tools/contracts";
20import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
21import * as Context from "effect/Context";
22import * as Data from "effect/Data";
23import * as DateTime from "effect/DateTime";
24import * as Duration from "effect/Duration";
25import * as Effect from "effect/Effect";
26import * as Layer from "effect/Layer";
27import * as Option from "effect/Option";
28import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
29
30import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts";
31import * as PairingGrantStore from "./PairingGrantStore.ts";
32import * as ServerSecretStore from "./ServerSecretStore.ts";
33import * as SessionStore from "./SessionStore.ts";
34import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts";
35
36export const DEFAULT_SESSION_SUBJECT = "cli-issued-session";
37export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap";
38
39export interface IssuedPairingLink {
40 readonly id: string;
41 readonly credential: string;
42 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
43 readonly subject: string;
44 readonly label?: string;
45 readonly createdAt: DateTime.Utc;
46 readonly expiresAt: DateTime.Utc;
47}
48
49export interface IssuedBearerSession {
50 readonly sessionId: AuthSessionId;
51 readonly token: string;
52 readonly method: "bearer-access-token";
53 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
54 readonly subject: string;
55 readonly client: AuthClientMetadata;
56 readonly expiresAt: DateTime.Utc;
57}
58
59export interface AuthenticatedSession {
60 readonly sessionId: AuthSessionId;
61 readonly subject: string;
62 readonly method: ServerAuthSessionMethod;
63 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
64 readonly expiresAt?: DateTime.DateTime;
65}
66
67export class ServerAuthInternalError extends Data.TaggedError("ServerAuthInternalError")<{
68 readonly message: string;
69 readonly cause?: unknown;
70}> {}
71
72export class ServerAuthInvalidCredentialError extends Data.TaggedError(
73 "ServerAuthInvalidCredentialError",
74)<{
75 readonly reason: "missing_credential" | "invalid_credential";
76 readonly cause?: unknown;
77}> {}
78
79export class ServerAuthInvalidRequestError extends Data.TaggedError(
80 "ServerAuthInvalidRequestError",
81)<{
82 readonly reason: "invalid_scope" | "scope_not_granted";
83}> {}
84
85export class ServerAuthForbiddenOperationError extends Data.TaggedError(
86 "ServerAuthForbiddenOperationError",
87)<{
88 readonly reason: "current_session_revoke_not_allowed";
89}> {}
90
91export interface EnvironmentAuthShape {
92 readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>;
93 readonly getSessionState: (
94 request: HttpServerRequest.HttpServerRequest,
95 ) => Effect.Effect<AuthSessionState, ServerAuthInternalError>;
96 readonly createBrowserSession: (
97 credential: string,
98 requestMetadata: AuthClientMetadata,
99 ) => Effect.Effect<
100 {
101 readonly response: AuthBrowserSessionResult;
102 readonly sessionToken: string;
103 },
104 ServerAuthInvalidCredentialError | ServerAuthInternalError
105 >;
106 readonly exchangeBootstrapCredentialForAccessToken: (
107 credential: string,
108 requestedScopes: ReadonlyArray<AuthEnvironmentScope> | undefined,
109 requestMetadata: AuthClientMetadata,
110 ) => Effect.Effect<
111 AuthAccessTokenResult,
112 ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError
113 >;
114 readonly createPairingLink: (input?: {
115 readonly ttl?: Duration.Duration;
116 readonly label?: string;
117 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
118 readonly subject?: string;
119 }) => Effect.Effect<IssuedPairingLink, ServerAuthInternalError>;
120 readonly issuePairingCredential: (
121 input?: AuthCreatePairingCredentialInput,
122 ) => Effect.Effect<AuthPairingCredentialResult, ServerAuthInternalError>;
123 readonly issueStartupPairingCredential: () => Effect.Effect<
124 AuthPairingCredentialResult,
125 ServerAuthInternalError
126 >;
127 readonly listPairingLinks: (input?: {
128 readonly excludeSubjects?: ReadonlyArray<string>;
129 }) => Effect.Effect<ReadonlyArray<AuthPairingLink>, ServerAuthInternalError>;
130 readonly revokePairingLink: (id: string) => Effect.Effect<boolean, ServerAuthInternalError>;
131 readonly issueSession: (input?: {
132 readonly ttl?: Duration.Duration;
133 readonly subject?: string;
134 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
135 readonly label?: string;
136 }) => Effect.Effect<IssuedBearerSession, ServerAuthInternalError>;
137 readonly listSessions: () => Effect.Effect<
138 ReadonlyArray<AuthClientSession>,
139 ServerAuthInternalError
140 >;
141 readonly revokeSession: (
142 sessionId: AuthSessionId,
143 ) => Effect.Effect<boolean, ServerAuthInternalError>;
144 readonly revokeOtherSessionsExcept: (
145 sessionId: AuthSessionId,
146 ) => Effect.Effect<number, ServerAuthInternalError>;
147 readonly listClientSessions: (
148 currentSessionId: AuthSessionId,
149 ) => Effect.Effect<ReadonlyArray<AuthClientSession>, ServerAuthInternalError>;
150 readonly revokeClientSession: (
151 currentSessionId: AuthSessionId,
152 targetSessionId: AuthSessionId,
153 ) => Effect.Effect<boolean, ServerAuthForbiddenOperationError | ServerAuthInternalError>;
154 readonly revokeOtherClientSessions: (
155 currentSessionId: AuthSessionId,
156 ) => Effect.Effect<number, ServerAuthInternalError>;
157 readonly authenticateHttpRequest: (
158 request: HttpServerRequest.HttpServerRequest,
159 ) => Effect.Effect<
160 AuthenticatedSession,
161 ServerAuthInvalidCredentialError | ServerAuthInternalError
162 >;
163 readonly authenticateWebSocketUpgrade: (
164 request: HttpServerRequest.HttpServerRequest,
165 ) => Effect.Effect<
166 AuthenticatedSession,
167 ServerAuthInvalidCredentialError | ServerAuthInternalError
168 >;
169 readonly issueWebSocketTicket: (
170 session: Pick<AuthenticatedSession, "sessionId">,
171 ) => Effect.Effect<AuthWebSocketTicketResult, ServerAuthInternalError>;
172 readonly issueStartupPairingUrl: (
173 baseUrl: string,
174 ) => Effect.Effect<string, ServerAuthInternalError>;
175}
176
177export class EnvironmentAuth extends Context.Service<EnvironmentAuth, EnvironmentAuthShape>()(
178 "t3/auth/EnvironmentAuth",
179) {}
180
181type BootstrapExchangeResult = {
182 readonly response: AuthBrowserSessionResult;
183 readonly sessionToken: string;
184};
185
186const AUTHORIZATION_PREFIX = "Bearer ";
187const WEBSOCKET_TICKET_QUERY_PARAM = "wsTicket";
188
189const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => {
190 const leftCanManage = left.scopes.includes(AuthAccessWriteScope);
191 const rightCanManage = right.scopes.includes(AuthAccessWriteScope);
192 if (leftCanManage !== rightCanManage) {
193 return leftCanManage ? -1 : 1;
194 }
195 if (left.connected !== right.connected) {
196 return left.connected ? -1 : 1;
197 }
198 return right.issuedAt.epochMilliseconds - left.issuedAt.epochMilliseconds;
199};
200
201const toInternalError =
202 (message: string) =>
203 (cause: unknown): ServerAuthInternalError =>
204 new ServerAuthInternalError({ message, cause });
205
206export function toBootstrapExchangeError(
207 cause: PairingGrantStore.BootstrapCredentialError,
208): ServerAuthInvalidCredentialError | ServerAuthInternalError {
209 if (cause._tag === "BootstrapCredentialInternalError") {
210 return new ServerAuthInternalError({
211 message: "Failed to validate bootstrap credential.",
212 cause,
213 });
214 }
215
216 return new ServerAuthInvalidCredentialError({
217 reason: "invalid_credential",
218 cause,
219 });
220}
221
222const mapSessionVerificationErrors = <A, R>(
223 effect: Effect.Effect<A, SessionStore.SessionCredentialError, R>,
224): Effect.Effect<A, ServerAuthInvalidCredentialError | ServerAuthInternalError, R> =>
225 effect.pipe(
226 Effect.catchTags({
227 SessionCredentialInvalidError: (cause) =>
228 Effect.fail(new ServerAuthInvalidCredentialError({ reason: "invalid_credential", cause })),
229 SessionCredentialInternalError: (cause) =>
230 Effect.fail(
231 new ServerAuthInternalError({
232 message: "Failed to validate session credential.",
233 cause,
234 }),
235 ),
236 }),
237 );
238
239function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | null {
240 const header = request.headers["authorization"];
241 if (typeof header !== "string" || !header.startsWith(AUTHORIZATION_PREFIX)) {
242 return null;
243 }
244 const token = header.slice(AUTHORIZATION_PREFIX.length).trim();
245 return token.length > 0 ? token : null;
246}
247
248export const make = Effect.fn("makeEnvironmentAuth")(function* () {
249 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
250 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
251 const sessions = yield* SessionStore.SessionStore;
252 const descriptor = yield* policy.getDescriptor();
253
254 const authenticateToken = (
255 token: string,
256 ): Effect.Effect<
257 AuthenticatedSession,
258 ServerAuthInvalidCredentialError | ServerAuthInternalError
259 > =>
260 sessions.verify(token).pipe(
261 Effect.tapErrorTag("SessionCredentialInvalidError", (cause) =>
262 Effect.logWarning("Rejected authenticated session credential.").pipe(
263 Effect.annotateLogs({
264 reason: cause.message,
265 }),
266 ),
267 ),
268 Effect.map((session) => ({
269 sessionId: session.sessionId,
270 subject: session.subject,
271 method: session.method,
272 scopes: session.scopes,
273 ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}),
274 })),
275 mapSessionVerificationErrors,
276 );
277
278 const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => {
279 const cookieToken = request.cookies[sessions.cookieName];
280 const bearerToken = parseBearerToken(request);
281 const credential = cookieToken ?? bearerToken;
282 if (!credential) {
283 return Effect.fail(new ServerAuthInvalidCredentialError({ reason: "missing_credential" }));
284 }
285 return authenticateToken(credential);
286 };
287
288 const getSessionState: EnvironmentAuthShape["getSessionState"] = (request) =>
289 authenticateRequest(request).pipe(
290 Effect.map(
291 (session) =>
292 ({
293 authenticated: true,
294 auth: descriptor,
295 scopes: session.scopes,
296 sessionMethod: session.method,
297 ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}),
298 }) satisfies AuthSessionState,
299 ),
300 Effect.catchTag("ServerAuthInvalidCredentialError", () =>
301 Effect.succeed({
302 authenticated: false,
303 auth: descriptor,
304 } satisfies AuthSessionState),
305 ),
306 Effect.withSpan("EnvironmentAuth.getSessionState"),
307 );
308
309 const createBrowserSession: EnvironmentAuthShape["createBrowserSession"] = (
310 credential,
311 requestMetadata,
312 ) =>
313 bootstrapCredentials.consume(credential).pipe(
314 Effect.mapError(toBootstrapExchangeError),
315 Effect.flatMap((grant) =>
316 sessions
317 .issue({
318 method: "browser-session-cookie",
319 subject: grant.subject,
320 scopes: grant.scopes,
321 client: {
322 ...requestMetadata,
323 ...(grant.label ? { label: grant.label } : {}),
324 },
325 })
326 .pipe(
327 Effect.mapError(
328 (cause) =>
329 new ServerAuthInternalError({
330 message: "Failed to issue authenticated session.",
331 cause,
332 }),
333 ),
334 ),
335 ),
336 Effect.map(
337 (session) =>
338 ({
339 response: {
340 authenticated: true,
341 scopes: session.scopes,
342 sessionMethod: session.method,
343 expiresAt: DateTime.toUtc(session.expiresAt),
344 } satisfies AuthBrowserSessionResult,
345 sessionToken: session.token,
346 }) satisfies BootstrapExchangeResult,
347 ),
348 Effect.withSpan("EnvironmentAuth.createBrowserSession"),
349 );
350
351 const exchangeBootstrapCredentialForAccessToken: EnvironmentAuthShape["exchangeBootstrapCredentialForAccessToken"] =
352 (credential, requestedScopes, requestMetadata) =>
353 bootstrapCredentials.consume(credential).pipe(
354 Effect.mapError(toBootstrapExchangeError),
355 Effect.flatMap((grant) =>
356 Effect.gen(function* () {
357 const grantedScopes = requestedScopes ?? grant.scopes;
358 if (!grantedScopes.every((scope) => grant.scopes.includes(scope))) {
359 return yield* new ServerAuthInvalidRequestError({
360 reason: "scope_not_granted",
361 });
362 }
363 return yield* sessions
364 .issue({
365 method: "bearer-access-token",
366 subject: grant.subject,
367 scopes: grantedScopes,
368 client: {
369 ...requestMetadata,
370 ...(grant.label ? { label: grant.label } : {}),
371 },
372 })
373 .pipe(
374 Effect.mapError(
375 (cause) =>
376 new ServerAuthInternalError({
377 message: "Failed to issue authenticated access token.",
378 cause,
379 }),
380 ),
381 );
382 }),
383 ),
384 Effect.flatMap((session) =>
385 DateTime.now.pipe(
386 Effect.map(
387 (now) =>
388 ({
389 access_token: session.token,
390 issued_token_type: AuthAccessTokenType,
391 token_type: "Bearer",
392 expires_in: Math.max(
393 0,
394 Math.floor(
395 (session.expiresAt.epochMilliseconds - now.epochMilliseconds) / 1000,
396 ),
397 ),
398 scope: encodeOAuthScope(session.scopes),
399 }) satisfies AuthAccessTokenResult,
400 ),
401 ),
402 ),
403 Effect.withSpan("EnvironmentAuth.exchangeBootstrapCredentialForAccessToken"),
404 );
405
406 const issuePairingCredentialForSubject = (input: {
407 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
408 readonly subject: string;
409 readonly label?: string;
410 }) =>
411 createPairingLink({
412 scopes: input.scopes,
413 subject: input.subject,
414 ...(input.label ? { label: input.label } : {}),
415 }).pipe(
416 Effect.map(
417 (issued) =>
418 ({
419 id: issued.id,
420 credential: issued.credential,
421 ...(issued.label ? { label: issued.label } : {}),
422 expiresAt: issued.expiresAt,
423 }) satisfies AuthPairingCredentialResult,
424 ),
425 );
426
427 const createPairingLink: EnvironmentAuthShape["createPairingLink"] = Effect.fn(
428 "EnvironmentAuth.createPairingLink",
429 )(
430 function* (input) {
431 const createdAt = yield* DateTime.now;
432 const issued = yield* bootstrapCredentials.issueOneTimeToken({
433 scopes: input?.scopes ?? AuthStandardClientScopes,
434 subject: input?.subject ?? "one-time-token",
435 ...(input?.ttl ? { ttl: input.ttl } : {}),
436 ...(input?.label ? { label: input.label } : {}),
437 });
438 return {
439 id: issued.id,
440 credential: issued.credential,
441 scopes: input?.scopes ?? AuthStandardClientScopes,
442 subject: input?.subject ?? "one-time-token",
443 ...(issued.label ? { label: issued.label } : {}),
444 createdAt: DateTime.toUtc(createdAt),
445 expiresAt: DateTime.toUtc(issued.expiresAt),
446 } satisfies IssuedPairingLink;
447 },
448 Effect.mapError(toInternalError("Failed to create pairing link.")),
449 );
450
451 const listPairingLinks: EnvironmentAuthShape["listPairingLinks"] = (input) =>
452 bootstrapCredentials.listActive().pipe(
453 Effect.map((pairingLinks) => {
454 const excludedSubjects = input?.excludeSubjects ?? [
455 INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT,
456 ];
457 return pairingLinks
458 .filter((pairingLink) => !excludedSubjects.includes(pairingLink.subject))
459 .toSorted(
460 (left, right) => right.createdAt.epochMilliseconds - left.createdAt.epochMilliseconds,
461 );
462 }),
463 Effect.mapError(toInternalError("Failed to list pairing links.")),
464 Effect.withSpan("EnvironmentAuth.listPairingLinks"),
465 );
466
467 const revokePairingLink: EnvironmentAuthShape["revokePairingLink"] = (id) =>
468 bootstrapCredentials
469 .revoke(id)
470 .pipe(
471 Effect.mapError(toInternalError("Failed to revoke pairing link.")),
472 Effect.withSpan("EnvironmentAuth.revokePairingLink"),
473 );
474
475 const issueSession: EnvironmentAuthShape["issueSession"] = (input) =>
476 sessions
477 .issue({
478 subject: input?.subject ?? DEFAULT_SESSION_SUBJECT,
479 method: "bearer-access-token",
480 scopes: input?.scopes ?? AuthAdministrativeScopes,
481 client: {
482 ...(input?.label ? { label: input.label } : {}),
483 deviceType: "bot",
484 },
485 ...(input?.ttl ? { ttl: input.ttl } : {}),
486 })
487 .pipe(
488 Effect.map(
489 (issued) =>
490 ({
491 sessionId: issued.sessionId,
492 token: issued.token,
493 method: "bearer-access-token",
494 scopes: issued.scopes,
495 subject: input?.subject ?? DEFAULT_SESSION_SUBJECT,
496 client: issued.client,
497 expiresAt: DateTime.toUtc(issued.expiresAt),
498 }) satisfies IssuedBearerSession,
499 ),
500 Effect.mapError(toInternalError("Failed to issue session token.")),
501 Effect.withSpan("EnvironmentAuth.issueSession"),
502 );
503
504 const listSessions: EnvironmentAuthShape["listSessions"] = () =>
505 sessions.listActive().pipe(
506 Effect.map((activeSessions) => activeSessions.toSorted(bySessionPriority)),
507 Effect.mapError(toInternalError("Failed to list sessions.")),
508 Effect.withSpan("EnvironmentAuth.listSessions"),
509 );
510
511 const revokeSession: EnvironmentAuthShape["revokeSession"] = (sessionId) =>
512 sessions
513 .revoke(sessionId)
514 .pipe(
515 Effect.mapError(toInternalError("Failed to revoke session.")),
516 Effect.withSpan("EnvironmentAuth.revokeSession"),
517 );
518
519 const revokeOtherSessionsExcept: EnvironmentAuthShape["revokeOtherSessionsExcept"] = (
520 sessionId,
521 ) =>
522 sessions
523 .revokeAllExcept(sessionId)
524 .pipe(
525 Effect.mapError(toInternalError("Failed to revoke other sessions.")),
526 Effect.withSpan("EnvironmentAuth.revokeOtherSessionsExcept"),
527 );
528
529 const issuePairingCredential: EnvironmentAuthShape["issuePairingCredential"] = (input) =>
530 issuePairingCredentialForSubject({
531 scopes: input?.scopes ?? AuthStandardClientScopes,
532 subject: "one-time-token",
533 ...(input?.label ? { label: input.label } : {}),
534 }).pipe(Effect.withSpan("EnvironmentAuth.issuePairingCredential"));
535
536 const issueStartupPairingCredential: EnvironmentAuthShape["issueStartupPairingCredential"] = () =>
537 issuePairingCredentialForSubject({
538 scopes: AuthAdministrativeScopes,
539 subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT,
540 }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential"));
541
542 const listClientSessions: EnvironmentAuthShape["listClientSessions"] = (currentSessionId) =>
543 listSessions().pipe(
544 Effect.map((clientSessions) =>
545 clientSessions.map(
546 (clientSession): AuthClientSession => ({
547 ...clientSession,
548 current: clientSession.sessionId === currentSessionId,
549 }),
550 ),
551 ),
552 Effect.withSpan("EnvironmentAuth.listClientSessions"),
553 );
554
555 const revokeClientSession: EnvironmentAuthShape["revokeClientSession"] = Effect.fn(
556 "EnvironmentAuth.revokeClientSession",
557 )(function* (currentSessionId, targetSessionId) {
558 if (currentSessionId === targetSessionId) {
559 return yield* new ServerAuthForbiddenOperationError({
560 reason: "current_session_revoke_not_allowed",
561 });
562 }
563 return yield* revokeSession(targetSessionId);
564 });
565
566 const revokeOtherClientSessions: EnvironmentAuthShape["revokeOtherClientSessions"] = (
567 currentSessionId,
568 ) =>
569 revokeOtherSessionsExcept(currentSessionId).pipe(
570 Effect.withSpan("EnvironmentAuth.revokeOtherClientSessions"),
571 );
572
573 const issueStartupPairingUrl: EnvironmentAuthShape["issueStartupPairingUrl"] = (baseUrl) =>
574 issueStartupPairingCredential().pipe(
575 Effect.map((issued) => {
576 const url = new URL(baseUrl);
577 url.pathname = "/pair";
578 url.searchParams.delete("token");
579 url.hash = new URLSearchParams([["token", issued.credential]]).toString();
580 return url.toString();
581 }),
582 Effect.withSpan("EnvironmentAuth.issueStartupPairingUrl"),
583 );
584
585 const issueWebSocketTicket: EnvironmentAuthShape["issueWebSocketTicket"] = (session) =>
586 sessions.issueWebSocketToken(session.sessionId).pipe(
587 Effect.mapError(
588 (cause) =>
589 new ServerAuthInternalError({
590 message: "Failed to issue websocket token.",
591 cause,
592 }),
593 ),
594 Effect.map(
595 (issued) =>
596 ({
597 ticket: issued.token,
598 expiresAt: DateTime.toUtc(issued.expiresAt),
599 }) satisfies AuthWebSocketTicketResult,
600 ),
601 Effect.withSpan("EnvironmentAuth.issueWebSocketTicket"),
602 );
603
604 const authenticateHttpRequest: EnvironmentAuthShape["authenticateHttpRequest"] = (request) =>
605 authenticateRequest(request).pipe(Effect.withSpan("EnvironmentAuth.authenticateHttpRequest"));
606
607 const authenticateWebSocketUpgrade: EnvironmentAuthShape["authenticateWebSocketUpgrade"] =
608 Effect.fn("EnvironmentAuth.authenticateWebSocketUpgrade")(function* (request) {
609 const requestUrl = HttpServerRequest.toURL(request);
610 if (Option.isSome(requestUrl)) {
611 const websocketTicket = requestUrl.value.searchParams.get(WEBSOCKET_TICKET_QUERY_PARAM);
612 if (websocketTicket && websocketTicket.trim().length > 0) {
613 return yield* sessions.verifyWebSocketToken(websocketTicket).pipe(
614 Effect.map((session) => ({
615 sessionId: session.sessionId,
616 subject: session.subject,
617 method: session.method,
618 scopes: session.scopes,
619 ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}),
620 })),
621 mapSessionVerificationErrors,
622 );
623 }
624 }
625
626 return yield* authenticateRequest(request);
627 });
628
629 return {
630 getDescriptor: () =>
631 Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuth.getDescriptor")),
632 getSessionState,
633 createBrowserSession,
634 exchangeBootstrapCredentialForAccessToken,
635 createPairingLink,
636 issuePairingCredential,
637 issueStartupPairingCredential,
638 listPairingLinks,
639 revokePairingLink,
640 issueSession,
641 listSessions,
642 revokeSession,
643 revokeOtherSessionsExcept,
644 listClientSessions,
645 revokeClientSession,
646 revokeOtherClientSessions,
647 authenticateHttpRequest,
648 authenticateWebSocketUpgrade,
649 issueWebSocketTicket,
650 issueStartupPairingUrl,
651 } satisfies EnvironmentAuthShape;
652});
653
654export const layer = Layer.effect(EnvironmentAuth, make()).pipe(
655 Layer.provideMerge(PairingGrantStore.layer),
656 Layer.provideMerge(SessionStore.layer),
657 Layer.provideMerge(EnvironmentAuthPolicy.layer),
658);
659
660export const storageLayer = Layer.mergeAll(ServerSecretStore.layer, SqlitePersistenceLayer);
661
662export const runtimeLayer = layer.pipe(Layer.provideMerge(storageLayer));
apps/server/src/auth/EnvironmentAuthAdmin.test.ts created+125
...@@ -0,0 +1,125 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import type { ServerConfigShape } from "../config.ts";
7import { ServerConfig } from "../config.ts";
8import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
9import * as EnvironmentAuth from "./EnvironmentAuth.ts";
10import * as ServerSecretStore from "./ServerSecretStore.ts";
11import * as SessionStore from "./SessionStore.ts";
12
13const makeServerConfigLayer = (
14 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
15) =>
16 Layer.effect(
17 ServerConfig,
18 Effect.gen(function* () {
19 const config = yield* ServerConfig;
20 return {
21 ...config,
22 ...overrides,
23 } satisfies ServerConfigShape;
24 }),
25 ).pipe(
26 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-control-plane-test-" })),
27 );
28
29const makeEnvironmentAuthLayer = (
30 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
31) =>
32 EnvironmentAuth.layer.pipe(
33 Layer.provideMerge(ServerSecretStore.layer),
34 Layer.provideMerge(SqlitePersistenceMemory),
35 Layer.provide(makeServerConfigLayer(overrides)),
36 );
37
38it.layer(NodeServices.layer)("EnvironmentAuth administrative operations", (it) => {
39 it.effect("creates, lists, and revokes client pairing links", () =>
40 Effect.gen(function* () {
41 const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
42
43 const created = yield* environmentAuth.createPairingLink({
44 scopes: ["orchestration:read"],
45 subject: "one-time-token",
46 label: "CI phone",
47 });
48 const listedBeforeRevoke = yield* environmentAuth.listPairingLinks();
49 const revoked = yield* environmentAuth.revokePairingLink(created.id);
50 const listedAfterRevoke = yield* environmentAuth.listPairingLinks();
51
52 expect(created.scopes).toEqual(["orchestration:read"]);
53 expect(created.credential.length).toBeGreaterThan(0);
54 expect(listedBeforeRevoke).toHaveLength(1);
55 expect(listedBeforeRevoke[0]?.id).toBe(created.id);
56 expect(listedBeforeRevoke[0]?.label).toBe("CI phone");
57 expect(listedBeforeRevoke[0]?.credential).toBe(created.credential);
58 expect(revoked).toBe(true);
59 expect(listedAfterRevoke).toHaveLength(0);
60 }).pipe(Effect.provide(makeEnvironmentAuthLayer())),
61 );
62
63 it.effect("issues bearer access token sessions without exposing raw tokens", () =>
64 Effect.gen(function* () {
65 const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
66 const sessionCredentials = yield* SessionStore.SessionStore;
67
68 const issued = yield* environmentAuth.issueSession({
69 label: "deploy-bot",
70 });
71 const verified = yield* sessionCredentials.verify(issued.token);
72 const listedBeforeRevoke = yield* environmentAuth.listSessions();
73 const revoked = yield* environmentAuth.revokeSession(issued.sessionId);
74 const listedAfterRevoke = yield* environmentAuth.listSessions();
75
76 expect(issued.method).toBe("bearer-access-token");
77 expect(issued.scopes).toEqual([
78 "orchestration:read",
79 "orchestration:operate",
80 "terminal:operate",
81 "review:write",
82 "relay:read",
83 "access:read",
84 "access:write",
85 "relay:write",
86 ]);
87 expect(issued.client.deviceType).toBe("bot");
88 expect(issued.client.label).toBe("deploy-bot");
89 expect(verified.sessionId).toBe(issued.sessionId);
90 expect(verified.scopes).toEqual([
91 "orchestration:read",
92 "orchestration:operate",
93 "terminal:operate",
94 "review:write",
95 "relay:read",
96 "access:read",
97 "access:write",
98 "relay:write",
99 ]);
100 expect(verified.method).toBe("bearer-access-token");
101 expect(listedBeforeRevoke).toHaveLength(1);
102 expect(listedBeforeRevoke[0]?.sessionId).toBe(issued.sessionId);
103 expect("token" in (listedBeforeRevoke[0] ?? {})).toBe(false);
104 expect(revoked).toBe(true);
105 expect(listedAfterRevoke).toHaveLength(0);
106 }).pipe(Effect.provide(makeEnvironmentAuthLayer())),
107 );
108
109 it.effect("surfaces lastConnectedAt through the listed session view", () =>
110 Effect.gen(function* () {
111 const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
112 const sessionCredentials = yield* SessionStore.SessionStore;
113
114 const issued = yield* environmentAuth.issueSession({
115 label: "remote-ipad",
116 });
117 const beforeConnect = yield* environmentAuth.listSessions();
118 yield* sessionCredentials.markConnected(issued.sessionId);
119 const afterConnect = yield* environmentAuth.listSessions();
120
121 expect(beforeConnect[0]?.lastConnectedAt).toBeNull();
122 expect(afterConnect[0]?.lastConnectedAt).not.toBeNull();
123 }).pipe(Effect.provide(makeEnvironmentAuthLayer())),
124 );
125});
apps/server/src/auth/EnvironmentAuthPolicy.test.ts created+114
...@@ -0,0 +1,114 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import type { ServerConfigShape } from "../config.ts";
7import { ServerConfig } from "../config.ts";
8import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts";
9
10const makeEnvironmentAuthPolicyLayer = (overrides?: Partial<ServerConfigShape>) =>
11 EnvironmentAuthPolicy.layer.pipe(
12 Layer.provide(
13 Layer.effect(
14 ServerConfig,
15 Effect.gen(function* () {
16 const config = yield* ServerConfig;
17 return {
18 ...config,
19 ...overrides,
20 } satisfies ServerConfigShape;
21 }),
22 ).pipe(
23 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-policy-test-" })),
24 ),
25 ),
26 );
27
28it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {
29 it.effect("uses desktop-managed-local policy for desktop mode", () =>
30 Effect.gen(function* () {
31 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
32 const descriptor = yield* policy.getDescriptor();
33
34 expect(descriptor.policy).toBe("desktop-managed-local");
35 expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]);
36 expect(descriptor.sessionCookieName).toBe("t3_session_3773");
37 }).pipe(
38 Effect.provide(
39 makeEnvironmentAuthPolicyLayer({
40 mode: "desktop",
41 port: 3773,
42 }),
43 ),
44 ),
45 );
46
47 it.effect("uses remote-reachable policy for desktop mode when bound beyond loopback", () =>
48 Effect.gen(function* () {
49 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
50 const descriptor = yield* policy.getDescriptor();
51
52 expect(descriptor.policy).toBe("remote-reachable");
53 expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap", "one-time-token"]);
54 }).pipe(
55 Effect.provide(
56 makeEnvironmentAuthPolicyLayer({
57 mode: "desktop",
58 host: "0.0.0.0",
59 }),
60 ),
61 ),
62 );
63
64 it.effect("uses loopback-browser policy for loopback web hosts", () =>
65 Effect.gen(function* () {
66 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
67 const descriptor = yield* policy.getDescriptor();
68
69 expect(descriptor.policy).toBe("loopback-browser");
70 expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]);
71 expect(descriptor.sessionCookieName).toBe("t3_session");
72 }).pipe(
73 Effect.provide(
74 makeEnvironmentAuthPolicyLayer({
75 mode: "web",
76 host: "127.0.0.1",
77 }),
78 ),
79 ),
80 );
81
82 it.effect("uses remote-reachable policy for wildcard web hosts", () =>
83 Effect.gen(function* () {
84 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
85 const descriptor = yield* policy.getDescriptor();
86
87 expect(descriptor.policy).toBe("remote-reachable");
88 expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]);
89 }).pipe(
90 Effect.provide(
91 makeEnvironmentAuthPolicyLayer({
92 mode: "web",
93 host: "0.0.0.0",
94 }),
95 ),
96 ),
97 );
98
99 it.effect("uses remote-reachable policy for non-loopback web hosts", () =>
100 Effect.gen(function* () {
101 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
102 const descriptor = yield* policy.getDescriptor();
103
104 expect(descriptor.policy).toBe("remote-reachable");
105 }).pipe(
106 Effect.provide(
107 makeEnvironmentAuthPolicyLayer({
108 mode: "web",
109 host: "192.168.1.50",
110 }),
111 ),
112 ),
113 );
114});
apps/server/src/auth/EnvironmentAuthPolicy.ts created+55
...@@ -0,0 +1,55 @@
1import type { ServerAuthDescriptor } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import { ServerConfig } from "../config.ts";
7import { resolveSessionCookieName } from "./utils.ts";
8import { isLoopbackHost, isWildcardHost } from "../startupAccess.ts";
9
10export interface EnvironmentAuthPolicyShape {
11 readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>;
12}
13
14export class EnvironmentAuthPolicy extends Context.Service<
15 EnvironmentAuthPolicy,
16 EnvironmentAuthPolicyShape
17>()("t3/auth/EnvironmentAuthPolicy") {}
18
19export const make = Effect.fn("makeEnvironmentAuthPolicy")(function* () {
20 const config = yield* ServerConfig;
21 const isRemoteReachable = isWildcardHost(config.host) || !isLoopbackHost(config.host);
22
23 const policy =
24 config.mode === "desktop"
25 ? isRemoteReachable
26 ? "remote-reachable"
27 : "desktop-managed-local"
28 : isRemoteReachable
29 ? "remote-reachable"
30 : "loopback-browser";
31
32 const bootstrapMethods: ServerAuthDescriptor["bootstrapMethods"] =
33 policy === "desktop-managed-local"
34 ? ["desktop-bootstrap"]
35 : config.mode === "desktop" && policy === "remote-reachable"
36 ? ["desktop-bootstrap", "one-time-token"]
37 : ["one-time-token"];
38
39 const descriptor: ServerAuthDescriptor = {
40 policy,
41 bootstrapMethods,
42 sessionMethods: ["browser-session-cookie", "bearer-access-token"],
43 sessionCookieName: resolveSessionCookieName({
44 mode: config.mode,
45 port: config.port,
46 }),
47 };
48
49 return {
50 getDescriptor: () =>
51 Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuthPolicy.getDescriptor")),
52 } satisfies EnvironmentAuthPolicyShape;
53});
54
55export const layer = Layer.effect(EnvironmentAuthPolicy, make());
apps/server/src/auth/Layers/AuthControlPlane.test.ts deleted-112
...@@ -1,112 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import type { ServerConfigShape } from "../../config.ts";
7import { ServerConfig } from "../../config.ts";
8import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts";
9import { ServerSecretStoreLive } from "./ServerSecretStore.ts";
10import { SessionCredentialServiceLive } from "./SessionCredentialService.ts";
11import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
12import { AuthControlPlane } from "../Services/AuthControlPlane.ts";
13import { makeAuthControlPlane } from "./AuthControlPlane.ts";
14import { SessionCredentialService } from "../Services/SessionCredentialService.ts";
15
16const makeServerConfigLayer = (
17 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
18) =>
19 Layer.effect(
20 ServerConfig,
21 Effect.gen(function* () {
22 const config = yield* ServerConfig;
23 return {
24 ...config,
25 ...overrides,
26 } satisfies ServerConfigShape;
27 }),
28 ).pipe(
29 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-control-plane-test-" })),
30 );
31
32const makeAuthControlPlaneLayer = (
33 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
34) =>
35 Layer.effect(AuthControlPlane, makeAuthControlPlane).pipe(
36 Layer.provideMerge(BootstrapCredentialServiceLive),
37 Layer.provideMerge(SessionCredentialServiceLive),
38 Layer.provideMerge(ServerSecretStoreLive),
39 Layer.provideMerge(SqlitePersistenceMemory),
40 Layer.provide(makeServerConfigLayer(overrides)),
41 );
42
43it.layer(NodeServices.layer)("AuthControlPlane", (it) => {
44 it.effect("creates, lists, and revokes client pairing links", () =>
45 Effect.gen(function* () {
46 const authControlPlane = yield* AuthControlPlane;
47
48 const created = yield* authControlPlane.createPairingLink({
49 role: "client",
50 subject: "one-time-token",
51 label: "CI phone",
52 });
53 const listedBeforeRevoke = yield* authControlPlane.listPairingLinks({ role: "client" });
54 const revoked = yield* authControlPlane.revokePairingLink(created.id);
55 const listedAfterRevoke = yield* authControlPlane.listPairingLinks({ role: "client" });
56
57 expect(created.role).toBe("client");
58 expect(created.credential.length).toBeGreaterThan(0);
59 expect(listedBeforeRevoke).toHaveLength(1);
60 expect(listedBeforeRevoke[0]?.id).toBe(created.id);
61 expect(listedBeforeRevoke[0]?.label).toBe("CI phone");
62 expect(listedBeforeRevoke[0]?.credential).toBe(created.credential);
63 expect(revoked).toBe(true);
64 expect(listedAfterRevoke).toHaveLength(0);
65 }).pipe(Effect.provide(makeAuthControlPlaneLayer())),
66 );
67
68 it.effect("issues bearer sessions and lists them without exposing raw tokens", () =>
69 Effect.gen(function* () {
70 const authControlPlane = yield* AuthControlPlane;
71 const sessionCredentials = yield* SessionCredentialService;
72
73 const issued = yield* authControlPlane.issueSession({
74 label: "deploy-bot",
75 });
76 const verified = yield* sessionCredentials.verify(issued.token);
77 const listedBeforeRevoke = yield* authControlPlane.listSessions();
78 const revoked = yield* authControlPlane.revokeSession(issued.sessionId);
79 const listedAfterRevoke = yield* authControlPlane.listSessions();
80
81 expect(issued.method).toBe("bearer-session-token");
82 expect(issued.role).toBe("owner");
83 expect(issued.client.deviceType).toBe("bot");
84 expect(issued.client.label).toBe("deploy-bot");
85 expect(verified.sessionId).toBe(issued.sessionId);
86 expect(verified.role).toBe("owner");
87 expect(verified.method).toBe("bearer-session-token");
88 expect(listedBeforeRevoke).toHaveLength(1);
89 expect(listedBeforeRevoke[0]?.sessionId).toBe(issued.sessionId);
90 expect("token" in (listedBeforeRevoke[0] ?? {})).toBe(false);
91 expect(revoked).toBe(true);
92 expect(listedAfterRevoke).toHaveLength(0);
93 }).pipe(Effect.provide(makeAuthControlPlaneLayer())),
94 );
95
96 it.effect("surfaces lastConnectedAt through the listed session view", () =>
97 Effect.gen(function* () {
98 const authControlPlane = yield* AuthControlPlane;
99 const sessionCredentials = yield* SessionCredentialService;
100
101 const issued = yield* authControlPlane.issueSession({
102 label: "remote-ipad",
103 });
104 const beforeConnect = yield* authControlPlane.listSessions();
105 yield* sessionCredentials.markConnected(issued.sessionId);
106 const afterConnect = yield* authControlPlane.listSessions();
107
108 expect(beforeConnect[0]?.lastConnectedAt).toBeNull();
109 expect(afterConnect[0]?.lastConnectedAt).not.toBeNull();
110 }).pipe(Effect.provide(makeAuthControlPlaneLayer())),
111 );
112});
apps/server/src/auth/Layers/AuthControlPlane.ts deleted-186
...@@ -1,186 +0,0 @@
1import type { AuthClientSession, AuthPairingLink } from "@t3tools/contracts";
2import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts";
7import { ServerSecretStoreLive } from "./ServerSecretStore.ts";
8import { SessionCredentialServiceLive } from "./SessionCredentialService.ts";
9import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts";
10import { SessionCredentialService } from "../Services/SessionCredentialService.ts";
11import { layerConfig as SqlitePersistenceLayerLive } from "../../persistence/Layers/Sqlite.ts";
12import {
13 AuthControlPlane,
14 AuthControlPlaneError,
15 DEFAULT_SESSION_SUBJECT,
16} from "../Services/AuthControlPlane.ts";
17import type {
18 AuthControlPlaneShape,
19 IssuedBearerSession,
20 IssuedPairingLink,
21} from "../Services/AuthControlPlane.ts";
22
23const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => {
24 if (left.role !== right.role) {
25 return left.role === "owner" ? -1 : 1;
26 }
27 if (left.connected !== right.connected) {
28 return left.connected ? -1 : 1;
29 }
30 return right.issuedAt.epochMilliseconds - left.issuedAt.epochMilliseconds;
31};
32
33const toAuthControlPlaneError =
34 (message: string) =>
35 (cause: unknown): AuthControlPlaneError =>
36 new AuthControlPlaneError({
37 message,
38 cause,
39 });
40
41export const makeAuthControlPlane = Effect.gen(function* () {
42 const bootstrapCredentials = yield* BootstrapCredentialService;
43 const sessions = yield* SessionCredentialService;
44
45 const createPairingLink: AuthControlPlaneShape["createPairingLink"] = (input) =>
46 Effect.gen(function* () {
47 const createdAt = yield* DateTime.now;
48 const issued = yield* bootstrapCredentials.issueOneTimeToken({
49 role: input?.role ?? "client",
50 subject: input?.subject ?? "one-time-token",
51 ...(input?.ttl ? { ttl: input.ttl } : {}),
52 ...(input?.label ? { label: input.label } : {}),
53 });
54 return {
55 id: issued.id,
56 credential: issued.credential,
57 role: input?.role ?? "client",
58 subject: input?.subject ?? "one-time-token",
59 ...(issued.label ? { label: issued.label } : {}),
60 createdAt: DateTime.toUtc(createdAt),
61 expiresAt: DateTime.toUtc(issued.expiresAt),
62 } satisfies IssuedPairingLink;
63 }).pipe(Effect.mapError(toAuthControlPlaneError("Failed to create pairing link.")));
64
65 const listPairingLinks: AuthControlPlaneShape["listPairingLinks"] = (input) =>
66 bootstrapCredentials.listActive().pipe(
67 Effect.map((pairingLinks) => {
68 const activeLinks: Array<AuthPairingLink> = [];
69 for (const pairingLink of pairingLinks) {
70 if (input?.role && pairingLink.role !== input.role) {
71 continue;
72 }
73 if (input?.excludeSubjects?.includes(pairingLink.subject)) {
74 continue;
75 }
76 activeLinks.push(
77 pairingLink.label
78 ? ({
79 id: pairingLink.id,
80 credential: pairingLink.credential,
81 role: pairingLink.role,
82 subject: pairingLink.subject,
83 label: pairingLink.label,
84 createdAt: pairingLink.createdAt,
85 expiresAt: pairingLink.expiresAt,
86 } satisfies AuthPairingLink)
87 : ({
88 id: pairingLink.id,
89 credential: pairingLink.credential,
90 role: pairingLink.role,
91 subject: pairingLink.subject,
92 createdAt: pairingLink.createdAt,
93 expiresAt: pairingLink.expiresAt,
94 } satisfies AuthPairingLink),
95 );
96 }
97 return activeLinks.toSorted(
98 (left, right) => right.createdAt.epochMilliseconds - left.createdAt.epochMilliseconds,
99 );
100 }),
101 Effect.mapError(toAuthControlPlaneError("Failed to list pairing links.")),
102 );
103
104 const revokePairingLink: AuthControlPlaneShape["revokePairingLink"] = (id) =>
105 bootstrapCredentials
106 .revoke(id)
107 .pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke pairing link.")));
108
109 const issueSession: AuthControlPlaneShape["issueSession"] = (input) =>
110 sessions
111 .issue({
112 subject: input?.subject ?? DEFAULT_SESSION_SUBJECT,
113 method: "bearer-session-token",
114 role: input?.role ?? "owner",
115 client: {
116 ...(input?.label ? { label: input.label } : {}),
117 deviceType: "bot",
118 },
119 ...(input?.ttl ? { ttl: input.ttl } : {}),
120 })
121 .pipe(
122 Effect.flatMap((issued) => {
123 if (issued.method !== "bearer-session-token") {
124 return Effect.fail(
125 new AuthControlPlaneError({
126 message: "CLI session issuance produced an unexpected session method.",
127 }),
128 );
129 }
130
131 return Effect.succeed({
132 sessionId: issued.sessionId,
133 token: issued.token,
134 method: "bearer-session-token" as const,
135 role: issued.role,
136 subject: input?.subject ?? DEFAULT_SESSION_SUBJECT,
137 client: issued.client,
138 expiresAt: DateTime.toUtc(issued.expiresAt),
139 } satisfies IssuedBearerSession);
140 }),
141 Effect.mapError(toAuthControlPlaneError("Failed to issue session token.")),
142 );
143
144 const listSessions: AuthControlPlaneShape["listSessions"] = () =>
145 sessions.listActive().pipe(
146 Effect.map((activeSessions) => activeSessions.toSorted(bySessionPriority)),
147 Effect.mapError(toAuthControlPlaneError("Failed to list sessions.")),
148 );
149
150 const revokeSession: AuthControlPlaneShape["revokeSession"] = (sessionId) =>
151 sessions
152 .revoke(sessionId)
153 .pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke session.")));
154
155 const revokeOtherSessionsExcept: AuthControlPlaneShape["revokeOtherSessionsExcept"] = (
156 sessionId,
157 ) =>
158 sessions
159 .revokeAllExcept(sessionId)
160 .pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke other sessions.")));
161
162 return {
163 createPairingLink,
164 listPairingLinks,
165 revokePairingLink,
166 issueSession,
167 listSessions,
168 revokeSession,
169 revokeOtherSessionsExcept,
170 } satisfies AuthControlPlaneShape;
171});
172
173export const AuthCoreLive = Layer.mergeAll(
174 BootstrapCredentialServiceLive,
175 SessionCredentialServiceLive,
176);
177
178export const AuthStorageLive = Layer.mergeAll(ServerSecretStoreLive, SqlitePersistenceLayerLive);
179
180export const AuthRuntimeLive = AuthCoreLive.pipe(Layer.provideMerge(AuthStorageLive));
181
182export const AuthControlPlaneLive = Layer.effect(AuthControlPlane, makeAuthControlPlane);
183
184export const AuthControlPlaneRuntimeLive = AuthControlPlaneLive.pipe(
185 Layer.provideMerge(AuthRuntimeLive),
186);
apps/server/src/auth/Layers/BootstrapCredentialService.test.ts deleted-153
...@@ -1,153 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as TestClock from "effect/testing/TestClock";
7
8import type { ServerConfigShape } from "../../config.ts";
9import { ServerConfig } from "../../config.ts";
10import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
11import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts";
12import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts";
13
14const makeServerConfigLayer = (
15 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
16) =>
17 Layer.effect(
18 ServerConfig,
19 Effect.gen(function* () {
20 const config = yield* ServerConfig;
21 return {
22 ...config,
23 ...overrides,
24 } satisfies ServerConfigShape;
25 }),
26 ).pipe(
27 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-bootstrap-test-" })),
28 );
29
30const makeBootstrapCredentialLayer = (
31 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
32) =>
33 BootstrapCredentialServiceLive.pipe(
34 Layer.provide(SqlitePersistenceMemory),
35 Layer.provide(makeServerConfigLayer(overrides)),
36 );
37
38it.layer(NodeServices.layer)("BootstrapCredentialServiceLive", (it) => {
39 it.effect("issues pairing tokens in a short manual-entry format", () =>
40 Effect.gen(function* () {
41 const bootstrapCredentials = yield* BootstrapCredentialService;
42 const issued = yield* bootstrapCredentials.issueOneTimeToken();
43
44 expect(issued.credential).toMatch(/^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{12}$/);
45 }).pipe(Effect.provide(makeBootstrapCredentialLayer())),
46 );
47
48 it.effect("issues one-time bootstrap tokens that can only be consumed once", () =>
49 Effect.gen(function* () {
50 const bootstrapCredentials = yield* BootstrapCredentialService;
51 const issued = yield* bootstrapCredentials.issueOneTimeToken({ label: "Julius iPhone" });
52 const first = yield* bootstrapCredentials.consume(issued.credential);
53 const second = yield* Effect.flip(bootstrapCredentials.consume(issued.credential));
54
55 expect(first.method).toBe("one-time-token");
56 expect(first.role).toBe("client");
57 expect(first.subject).toBe("one-time-token");
58 expect(first.label).toBe("Julius iPhone");
59 expect(issued.label).toBe("Julius iPhone");
60 expect(second._tag).toBe("BootstrapCredentialError");
61 expect(second.message).toContain("Unknown bootstrap credential");
62 }).pipe(Effect.provide(makeBootstrapCredentialLayer())),
63 );
64
65 it.effect("atomically consumes a one-time token when multiple requests race", () =>
66 Effect.gen(function* () {
67 const bootstrapCredentials = yield* BootstrapCredentialService;
68 const token = yield* bootstrapCredentials.issueOneTimeToken();
69 const results = yield* Effect.all(
70 Array.from({ length: 8 }, () =>
71 Effect.result(bootstrapCredentials.consume(token.credential)),
72 ),
73 {
74 concurrency: "unbounded",
75 },
76 );
77
78 const successes = results.filter((result) => result._tag === "Success");
79 const failures = results.filter((result) => result._tag === "Failure");
80
81 expect(successes).toHaveLength(1);
82 expect(failures).toHaveLength(7);
83 for (const failure of failures) {
84 expect(failure.failure._tag).toBe("BootstrapCredentialError");
85 expect(failure.failure.message).toContain("Unknown bootstrap credential");
86 }
87 }).pipe(Effect.provide(makeBootstrapCredentialLayer())),
88 );
89
90 it.effect("seeds the desktop bootstrap credential as a one-time grant", () =>
91 Effect.gen(function* () {
92 const bootstrapCredentials = yield* BootstrapCredentialService;
93 const first = yield* bootstrapCredentials.consume("desktop-bootstrap-token");
94 const second = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token"));
95
96 expect(first.method).toBe("desktop-bootstrap");
97 expect(first.role).toBe("owner");
98 expect(first.subject).toBe("desktop-bootstrap");
99 expect(second._tag).toBe("BootstrapCredentialError");
100 expect(second.status).toBe(401);
101 }).pipe(
102 Effect.provide(
103 makeBootstrapCredentialLayer({
104 desktopBootstrapToken: "desktop-bootstrap-token",
105 }),
106 ),
107 ),
108 );
109
110 it.effect("reports seeded desktop bootstrap credentials as expired after their ttl", () =>
111 Effect.gen(function* () {
112 const bootstrapCredentials = yield* BootstrapCredentialService;
113
114 yield* TestClock.adjust(Duration.minutes(6));
115 const expired = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token"));
116
117 expect(expired._tag).toBe("BootstrapCredentialError");
118 expect(expired.status).toBe(401);
119 expect(expired.message).toContain("Bootstrap credential expired");
120 }).pipe(
121 Effect.provide(
122 Layer.merge(
123 makeBootstrapCredentialLayer({
124 desktopBootstrapToken: "desktop-bootstrap-token",
125 }),
126 TestClock.layer(),
127 ),
128 ),
129 ),
130 );
131
132 it.effect("lists and revokes active pairing links", () =>
133 Effect.gen(function* () {
134 const bootstrapCredentials = yield* BootstrapCredentialService;
135 const first = yield* bootstrapCredentials.issueOneTimeToken();
136 const second = yield* bootstrapCredentials.issueOneTimeToken({ role: "owner" });
137
138 const activeBeforeRevoke = yield* bootstrapCredentials.listActive();
139 expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(first.id);
140 expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(second.id);
141
142 const revoked = yield* bootstrapCredentials.revoke(first.id);
143 const activeAfterRevoke = yield* bootstrapCredentials.listActive();
144 const revokedConsume = yield* Effect.flip(bootstrapCredentials.consume(first.credential));
145
146 expect(revoked).toBe(true);
147 expect(activeAfterRevoke.map((entry) => entry.id)).not.toContain(first.id);
148 expect(activeAfterRevoke.map((entry) => entry.id)).toContain(second.id);
149 expect(revokedConsume.message).toContain("no longer available");
150 expect(revokedConsume.status).toBe(401);
151 }).pipe(Effect.provide(makeBootstrapCredentialLayer())),
152 );
153});
apps/server/src/auth/Layers/BootstrapCredentialService.ts deleted-304
...@@ -1,304 +0,0 @@
1import type { AuthPairingLink } from "@t3tools/contracts";
2import * as Crypto from "effect/Crypto";
3import * as DateTime from "effect/DateTime";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as PubSub from "effect/PubSub";
8import * as Ref from "effect/Ref";
9import * as Stream from "effect/Stream";
10import * as Option from "effect/Option";
11
12import { ServerConfig } from "../../config.ts";
13import { AuthPairingLinkRepositoryLive } from "../../persistence/Layers/AuthPairingLinks.ts";
14import { AuthPairingLinkRepository } from "../../persistence/Services/AuthPairingLinks.ts";
15import {
16 BootstrapCredentialError,
17 BootstrapCredentialService,
18 type BootstrapCredentialChange,
19 type BootstrapCredentialServiceShape,
20 type BootstrapGrant,
21 type IssuedBootstrapCredential,
22} from "../Services/BootstrapCredentialService.ts";
23
24interface StoredBootstrapGrant extends BootstrapGrant {
25 readonly remainingUses: number | "unbounded";
26}
27
28type ConsumeResult =
29 | {
30 readonly _tag: "error";
31 readonly reason: "not-found" | "expired";
32 readonly error: BootstrapCredentialError;
33 }
34 | {
35 readonly _tag: "success";
36 readonly grant: BootstrapGrant;
37 };
38
39const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5);
40const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
41const PAIRING_TOKEN_LENGTH = 12;
42
43const generatePairingToken = (): string => {
44 const randomBytes = crypto.getRandomValues(new Uint8Array(PAIRING_TOKEN_LENGTH));
45
46 return Array.from(randomBytes, (value) => PAIRING_TOKEN_ALPHABET[value & 31]).join("");
47};
48
49export const makeBootstrapCredentialService = Effect.gen(function* () {
50 const crypto = yield* Crypto.Crypto;
51 const config = yield* ServerConfig;
52 const pairingLinks = yield* AuthPairingLinkRepository;
53 const seededGrantsRef = yield* Ref.make(new Map<string, StoredBootstrapGrant>());
54 const changesPubSub = yield* PubSub.unbounded<BootstrapCredentialChange>();
55
56 const invalidBootstrapCredentialError = (message: string) =>
57 new BootstrapCredentialError({
58 message,
59 status: 401,
60 });
61
62 const internalBootstrapCredentialError = (message: string, cause: unknown) =>
63 new BootstrapCredentialError({
64 message,
65 status: 500,
66 cause,
67 });
68
69 const seedGrant = (credential: string, grant: StoredBootstrapGrant) =>
70 Ref.update(seededGrantsRef, (current) => {
71 const next = new Map(current);
72 next.set(credential, grant);
73 return next;
74 });
75
76 const emitUpsert = (pairingLink: AuthPairingLink) =>
77 PubSub.publish(changesPubSub, {
78 type: "pairingLinkUpserted",
79 pairingLink,
80 }).pipe(Effect.asVoid);
81
82 const emitRemoved = (id: string) =>
83 PubSub.publish(changesPubSub, {
84 type: "pairingLinkRemoved",
85 id,
86 }).pipe(Effect.asVoid);
87
88 if (config.desktopBootstrapToken) {
89 const now = yield* DateTime.now;
90 yield* seedGrant(config.desktopBootstrapToken, {
91 method: "desktop-bootstrap",
92 role: "owner",
93 subject: "desktop-bootstrap",
94 expiresAt: DateTime.add(now, {
95 milliseconds: Duration.toMillis(DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES),
96 }),
97 remainingUses: 1,
98 });
99 }
100
101 const toBootstrapCredentialError = (message: string) => (cause: unknown) =>
102 internalBootstrapCredentialError(message, cause);
103
104 const listActive: BootstrapCredentialServiceShape["listActive"] = () =>
105 Effect.gen(function* () {
106 const now = yield* DateTime.now;
107 const rows = yield* pairingLinks.listActive({ now });
108
109 return rows.map((row) =>
110 row.label
111 ? ({
112 id: row.id,
113 credential: row.credential,
114 role: row.role,
115 subject: row.subject,
116 label: row.label,
117 createdAt: row.createdAt,
118 expiresAt: row.expiresAt,
119 } satisfies AuthPairingLink)
120 : ({
121 id: row.id,
122 credential: row.credential,
123 role: row.role,
124 subject: row.subject,
125 createdAt: row.createdAt,
126 expiresAt: row.expiresAt,
127 } satisfies AuthPairingLink),
128 );
129 }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to load active pairing links.")));
130
131 const revoke: BootstrapCredentialServiceShape["revoke"] = (id) =>
132 Effect.gen(function* () {
133 const revokedAt = yield* DateTime.now;
134 const revoked = yield* pairingLinks.revoke({
135 id,
136 revokedAt,
137 });
138 if (revoked) {
139 yield* emitRemoved(id);
140 }
141 return revoked;
142 }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to revoke pairing link.")));
143
144 const issueOneTimeToken: BootstrapCredentialServiceShape["issueOneTimeToken"] = (input) =>
145 Effect.gen(function* () {
146 const id = yield* crypto.randomUUIDv4;
147 const credential = generatePairingToken();
148 const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES;
149 const now = yield* DateTime.now;
150 const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) });
151 const issued: IssuedBootstrapCredential = {
152 id,
153 credential,
154 ...(input?.label ? { label: input.label } : {}),
155 expiresAt,
156 };
157 yield* pairingLinks.create({
158 id,
159 credential,
160 method: "one-time-token",
161 role: input?.role ?? "client",
162 subject: input?.subject ?? "one-time-token",
163 label: input?.label ?? null,
164 createdAt: now,
165 expiresAt: expiresAt,
166 });
167 yield* emitUpsert({
168 id,
169 credential,
170 role: input?.role ?? "client",
171 subject: input?.subject ?? "one-time-token",
172 ...(input?.label ? { label: input.label } : {}),
173 createdAt: now,
174 expiresAt,
175 });
176 return issued;
177 }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to issue pairing credential.")));
178
179 const consume: BootstrapCredentialServiceShape["consume"] = (credential) =>
180 Effect.gen(function* () {
181 const now = yield* DateTime.now;
182 const seededResult: ConsumeResult = yield* Ref.modify(
183 seededGrantsRef,
184 (current): readonly [ConsumeResult, Map<string, StoredBootstrapGrant>] => {
185 const grant = current.get(credential);
186 if (!grant) {
187 return [
188 {
189 _tag: "error",
190 reason: "not-found",
191 error: invalidBootstrapCredentialError("Unknown bootstrap credential."),
192 },
193 current,
194 ];
195 }
196
197 const next = new Map(current);
198 if (DateTime.isGreaterThanOrEqualTo(now, grant.expiresAt)) {
199 next.delete(credential);
200 return [
201 {
202 _tag: "error",
203 reason: "expired",
204 error: invalidBootstrapCredentialError("Bootstrap credential expired."),
205 },
206 next,
207 ];
208 }
209
210 const remainingUses = grant.remainingUses;
211 if (typeof remainingUses === "number") {
212 if (remainingUses <= 1) {
213 next.delete(credential);
214 } else {
215 next.set(credential, {
216 ...grant,
217 remainingUses: remainingUses - 1,
218 });
219 }
220 }
221
222 return [
223 {
224 _tag: "success",
225 grant: {
226 method: grant.method,
227 role: grant.role,
228 subject: grant.subject,
229 ...(grant.label ? { label: grant.label } : {}),
230 expiresAt: grant.expiresAt,
231 } satisfies BootstrapGrant,
232 },
233 next,
234 ];
235 },
236 );
237
238 if (seededResult._tag === "success") {
239 return seededResult.grant;
240 }
241 if (seededResult.reason !== "not-found") {
242 return yield* seededResult.error;
243 }
244
245 const consumed = yield* pairingLinks.consumeAvailable({
246 credential,
247 consumedAt: now,
248 now,
249 });
250
251 if (Option.isSome(consumed)) {
252 yield* emitRemoved(consumed.value.id);
253 return {
254 method: consumed.value.method,
255 role: consumed.value.role,
256 subject: consumed.value.subject,
257 ...(consumed.value.label ? { label: consumed.value.label } : {}),
258 expiresAt: consumed.value.expiresAt,
259 } satisfies BootstrapGrant;
260 }
261
262 const matching = yield* pairingLinks.getByCredential({ credential });
263 if (Option.isNone(matching)) {
264 return yield* invalidBootstrapCredentialError("Unknown bootstrap credential.");
265 }
266
267 if (matching.value.revokedAt !== null) {
268 return yield* invalidBootstrapCredentialError(
269 "Bootstrap credential is no longer available.",
270 );
271 }
272
273 if (matching.value.consumedAt !== null) {
274 return yield* invalidBootstrapCredentialError("Unknown bootstrap credential.");
275 }
276
277 if (DateTime.isGreaterThanOrEqualTo(now, matching.value.expiresAt)) {
278 return yield* invalidBootstrapCredentialError("Bootstrap credential expired.");
279 }
280
281 return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available.");
282 }).pipe(
283 Effect.mapError((cause) =>
284 cause instanceof BootstrapCredentialError
285 ? cause
286 : internalBootstrapCredentialError("Failed to consume bootstrap credential.", cause),
287 ),
288 );
289
290 return {
291 issueOneTimeToken,
292 listActive,
293 get streamChanges() {
294 return Stream.fromPubSub(changesPubSub);
295 },
296 revoke,
297 consume,
298 } satisfies BootstrapCredentialServiceShape;
299});
300
301export const BootstrapCredentialServiceLive = Layer.effect(
302 BootstrapCredentialService,
303 makeBootstrapCredentialService,
304).pipe(Layer.provideMerge(AuthPairingLinkRepositoryLive));
apps/server/src/auth/Layers/ServerAuth.test.ts deleted-183
...@@ -1,183 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import type { ServerConfigShape } from "../../config.ts";
7import { ServerConfig } from "../../config.ts";
8import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
9import { BootstrapCredentialError } from "../Services/BootstrapCredentialService.ts";
10import { ServerAuth, type ServerAuthShape } from "../Services/ServerAuth.ts";
11import { ServerAuthLive, toBootstrapExchangeAuthError } from "./ServerAuth.ts";
12import { ServerSecretStoreLive } from "./ServerSecretStore.ts";
13
14const makeServerConfigLayer = (overrides?: Partial<ServerConfigShape>) =>
15 Layer.effect(
16 ServerConfig,
17 Effect.gen(function* () {
18 const config = yield* ServerConfig;
19 return {
20 ...config,
21 ...overrides,
22 } satisfies ServerConfigShape;
23 }),
24 ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-server-test-" })));
25
26const makeServerAuthLayer = (overrides?: Partial<ServerConfigShape>) =>
27 ServerAuthLive.pipe(
28 Layer.provide(SqlitePersistenceMemory),
29 Layer.provide(ServerSecretStoreLive),
30 Layer.provide(makeServerConfigLayer(overrides)),
31 );
32
33const makeCookieRequest = (
34 sessionToken: string,
35): Parameters<ServerAuthShape["authenticateHttpRequest"]>[0] =>
36 ({
37 cookies: {
38 t3_session: sessionToken,
39 },
40 headers: {},
41 }) as unknown as Parameters<ServerAuthShape["authenticateHttpRequest"]>[0];
42
43const requestMetadata = {
44 deviceType: "desktop" as const,
45 os: "macOS",
46 browser: "Chrome",
47 ipAddress: "192.168.1.23",
48};
49
50it.layer(NodeServices.layer)("ServerAuthLive", (it) => {
51 it.effect("maps invalid bootstrap credential failures to 401", () =>
52 Effect.sync(() => {
53 const error = toBootstrapExchangeAuthError(
54 new BootstrapCredentialError({
55 message: "Unknown bootstrap credential.",
56 status: 401,
57 }),
58 );
59
60 expect(error.status).toBe(401);
61 expect(error.message).toBe("Invalid bootstrap credential.");
62 }),
63 );
64
65 it.effect("maps unexpected bootstrap failures to 500", () =>
66 Effect.sync(() => {
67 const error = toBootstrapExchangeAuthError(
68 new BootstrapCredentialError({
69 message: "Failed to consume bootstrap credential.",
70 status: 500,
71 cause: new Error("sqlite is unavailable"),
72 }),
73 );
74
75 expect(error.status).toBe(500);
76 expect(error.message).toBe("Failed to validate bootstrap credential.");
77 }),
78 );
79
80 it.effect("issues client pairing credentials by default", () =>
81 Effect.gen(function* () {
82 const serverAuth = yield* ServerAuth;
83
84 const pairingCredential = yield* serverAuth.issuePairingCredential();
85 const exchanged = yield* serverAuth.exchangeBootstrapCredential(
86 pairingCredential.credential,
87 requestMetadata,
88 );
89 const verified = yield* serverAuth.authenticateHttpRequest(
90 makeCookieRequest(exchanged.sessionToken),
91 );
92
93 expect(verified.sessionId.length).toBeGreaterThan(0);
94 expect(verified.role).toBe("client");
95 expect(verified.subject).toBe("one-time-token");
96 }).pipe(Effect.provide(makeServerAuthLayer())),
97 );
98
99 it.effect("issues startup pairing URLs that bootstrap owner sessions", () =>
100 Effect.gen(function* () {
101 const serverAuth = yield* ServerAuth;
102
103 const pairingUrl = yield* serverAuth.issueStartupPairingUrl("http://127.0.0.1:3773");
104 const token = new URLSearchParams(new URL(pairingUrl).hash.slice(1)).get("token");
105 const listedPairingLinks = yield* serverAuth.listPairingLinks();
106 expect(token).toBeTruthy();
107 expect(
108 listedPairingLinks.some((pairingLink) => pairingLink.subject === "owner-bootstrap"),
109 ).toBe(false);
110
111 const exchanged = yield* serverAuth.exchangeBootstrapCredential(token ?? "", requestMetadata);
112 const verified = yield* serverAuth.authenticateHttpRequest(
113 makeCookieRequest(exchanged.sessionToken),
114 );
115
116 expect(verified.role).toBe("owner");
117 expect(verified.subject).toBe("owner-bootstrap");
118 }).pipe(Effect.provide(makeServerAuthLayer())),
119 );
120
121 it.effect("lists pairing links and revokes other client sessions while keeping the owner", () =>
122 Effect.gen(function* () {
123 const serverAuth = yield* ServerAuth;
124
125 const ownerExchange = yield* serverAuth.exchangeBootstrapCredential(
126 "desktop-bootstrap-token",
127 requestMetadata,
128 );
129 const ownerSession = yield* serverAuth.authenticateHttpRequest(
130 makeCookieRequest(ownerExchange.sessionToken),
131 );
132 const pairingCredential = yield* serverAuth.issuePairingCredential({
133 label: "Julius iPhone",
134 });
135 const listedPairingLinks = yield* serverAuth.listPairingLinks();
136 const clientExchange = yield* serverAuth.exchangeBootstrapCredential(
137 pairingCredential.credential,
138 {
139 ...requestMetadata,
140 deviceType: "mobile",
141 os: "iOS",
142 browser: "Safari",
143 ipAddress: "192.168.1.88",
144 },
145 );
146 const clientSession = yield* serverAuth.authenticateHttpRequest(
147 makeCookieRequest(clientExchange.sessionToken),
148 );
149 const clientsBeforeRevoke = yield* serverAuth.listClientSessions(ownerSession.sessionId);
150 const revokedCount = yield* serverAuth.revokeOtherClientSessions(ownerSession.sessionId);
151 const clientsAfterRevoke = yield* serverAuth.listClientSessions(ownerSession.sessionId);
152
153 expect(listedPairingLinks.map((entry) => entry.id)).toContain(pairingCredential.id);
154 expect(listedPairingLinks.find((entry) => entry.id === pairingCredential.id)?.label).toBe(
155 "Julius iPhone",
156 );
157 expect(clientsBeforeRevoke).toHaveLength(2);
158 expect(
159 clientsBeforeRevoke.find((entry) => entry.sessionId === ownerSession.sessionId)?.current,
160 ).toBe(true);
161 expect(
162 clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.current,
163 ).toBe(false);
164 expect(
165 clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.client
166 .label,
167 ).toBe("Julius iPhone");
168 expect(
169 clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.client
170 .deviceType,
171 ).toBe("mobile");
172 expect(revokedCount).toBe(1);
173 expect(clientsAfterRevoke).toHaveLength(1);
174 expect(clientsAfterRevoke[0]?.sessionId).toBe(ownerSession.sessionId);
175 }).pipe(
176 Effect.provide(
177 makeServerAuthLayer({
178 desktopBootstrapToken: "desktop-bootstrap-token",
179 }),
180 ),
181 ),
182 );
183});
apps/server/src/auth/Layers/ServerAuth.ts deleted-398
...@@ -1,398 +0,0 @@
1import {
2 type AuthBearerBootstrapResult,
3 type AuthClientSession,
4 type AuthBootstrapResult,
5 type AuthPairingCredentialResult,
6 type AuthSessionState,
7 type AuthWebSocketTokenResult,
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 HttpServerRequest from "effect/unstable/http/HttpServerRequest";
14
15import { AuthControlPlane } from "../Services/AuthControlPlane.ts";
16import { ServerAuthPolicyLive } from "./ServerAuthPolicy.ts";
17import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts";
18import { BootstrapCredentialError } from "../Services/BootstrapCredentialService.ts";
19import { ServerAuthPolicy } from "../Services/ServerAuthPolicy.ts";
20import {
21 ServerAuth,
22 type AuthenticatedSession,
23 AuthError,
24 type ServerAuthShape,
25} from "../Services/ServerAuth.ts";
26import {
27 SessionCredentialError,
28 SessionCredentialService,
29} from "../Services/SessionCredentialService.ts";
30import { AuthControlPlaneLive, AuthCoreLive } from "./AuthControlPlane.ts";
31
32type BootstrapExchangeResult = {
33 readonly response: AuthBootstrapResult;
34 readonly sessionToken: string;
35};
36
37const AUTHORIZATION_PREFIX = "Bearer ";
38const WEBSOCKET_TOKEN_QUERY_PARAM = "wsToken";
39
40export function toBootstrapExchangeAuthError(cause: BootstrapCredentialError): AuthError {
41 if (cause.status === 500) {
42 return new AuthError({
43 message: "Failed to validate bootstrap credential.",
44 status: 500,
45 cause,
46 });
47 }
48
49 return new AuthError({
50 message: "Invalid bootstrap credential.",
51 status: 401,
52 cause,
53 });
54}
55
56function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | null {
57 const header = request.headers["authorization"];
58 if (typeof header !== "string" || !header.startsWith(AUTHORIZATION_PREFIX)) {
59 return null;
60 }
61 const token = header.slice(AUTHORIZATION_PREFIX.length).trim();
62 return token.length > 0 ? token : null;
63}
64
65export const makeServerAuth = Effect.gen(function* () {
66 const policy = yield* ServerAuthPolicy;
67 const bootstrapCredentials = yield* BootstrapCredentialService;
68 const authControlPlane = yield* AuthControlPlane;
69 const sessions = yield* SessionCredentialService;
70 const descriptor = yield* policy.getDescriptor();
71
72 const authenticateToken = (token: string): Effect.Effect<AuthenticatedSession, AuthError> =>
73 sessions.verify(token).pipe(
74 Effect.tapError((cause: SessionCredentialError) =>
75 Effect.logWarning("Rejected authenticated session credential.").pipe(
76 Effect.annotateLogs({
77 reason: cause.message,
78 }),
79 ),
80 ),
81 Effect.map((session) => ({
82 sessionId: session.sessionId,
83 subject: session.subject,
84 method: session.method,
85 role: session.role,
86 ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}),
87 })),
88 Effect.mapError(
89 (cause) =>
90 new AuthError({
91 message: "Unauthorized request.",
92 status: 401,
93 cause,
94 }),
95 ),
96 );
97
98 const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => {
99 const cookieToken = request.cookies[sessions.cookieName];
100 const bearerToken = parseBearerToken(request);
101 const credential = cookieToken ?? bearerToken;
102 if (!credential) {
103 return Effect.fail(
104 new AuthError({
105 message: "Authentication required.",
106 status: 401,
107 }),
108 );
109 }
110 return authenticateToken(credential);
111 };
112
113 const getSessionState: ServerAuthShape["getSessionState"] = (request) =>
114 authenticateRequest(request).pipe(
115 Effect.map(
116 (session) =>
117 ({
118 authenticated: true,
119 auth: descriptor,
120 role: session.role,
121 sessionMethod: session.method,
122 ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}),
123 }) satisfies AuthSessionState,
124 ),
125 Effect.catchTag("AuthError", () =>
126 Effect.succeed({
127 authenticated: false,
128 auth: descriptor,
129 } satisfies AuthSessionState),
130 ),
131 );
132
133 const exchangeBootstrapCredential: ServerAuthShape["exchangeBootstrapCredential"] = (
134 credential,
135 requestMetadata,
136 ) =>
137 bootstrapCredentials.consume(credential).pipe(
138 Effect.mapError(toBootstrapExchangeAuthError),
139 Effect.flatMap((grant) =>
140 sessions
141 .issue({
142 method: "browser-session-cookie",
143 subject: grant.subject,
144 role: grant.role,
145 client: {
146 ...requestMetadata,
147 ...(grant.label ? { label: grant.label } : {}),
148 },
149 })
150 .pipe(
151 Effect.mapError(
152 (cause) =>
153 new AuthError({
154 message: "Failed to issue authenticated session.",
155 cause,
156 }),
157 ),
158 ),
159 ),
160 Effect.map(
161 (session) =>
162 ({
163 response: {
164 authenticated: true,
165 role: session.role,
166 sessionMethod: session.method,
167 expiresAt: DateTime.toUtc(session.expiresAt),
168 } satisfies AuthBootstrapResult,
169 sessionToken: session.token,
170 }) satisfies BootstrapExchangeResult,
171 ),
172 );
173
174 const exchangeBootstrapCredentialForBearerSession: ServerAuthShape["exchangeBootstrapCredentialForBearerSession"] =
175 (credential, requestMetadata) =>
176 bootstrapCredentials.consume(credential).pipe(
177 Effect.mapError(toBootstrapExchangeAuthError),
178 Effect.flatMap((grant) =>
179 sessions
180 .issue({
181 method: "bearer-session-token",
182 subject: grant.subject,
183 role: grant.role,
184 client: {
185 ...requestMetadata,
186 ...(grant.label ? { label: grant.label } : {}),
187 },
188 })
189 .pipe(
190 Effect.mapError(
191 (cause) =>
192 new AuthError({
193 message: "Failed to issue authenticated session.",
194 cause,
195 }),
196 ),
197 ),
198 ),
199 Effect.map(
200 (session) =>
201 ({
202 authenticated: true,
203 role: session.role,
204 sessionMethod: "bearer-session-token",
205 expiresAt: DateTime.toUtc(session.expiresAt),
206 sessionToken: session.token,
207 }) satisfies AuthBearerBootstrapResult,
208 ),
209 );
210
211 const issuePairingCredential: ServerAuthShape["issuePairingCredential"] = (input) =>
212 authControlPlane
213 .createPairingLink({
214 role: input?.role ?? "client",
215 subject: input?.role === "owner" ? "owner-bootstrap" : "one-time-token",
216 ...(input?.label ? { label: input.label } : {}),
217 })
218 .pipe(
219 Effect.mapError(
220 (cause) =>
221 new AuthError({
222 message: "Failed to issue pairing credential.",
223 cause,
224 }),
225 ),
226 Effect.map(
227 (issued) =>
228 ({
229 id: issued.id,
230 credential: issued.credential,
231 ...(issued.label ? { label: issued.label } : {}),
232 expiresAt: issued.expiresAt,
233 }) satisfies AuthPairingCredentialResult,
234 ),
235 );
236
237 const listPairingLinks: ServerAuthShape["listPairingLinks"] = () =>
238 authControlPlane
239 .listPairingLinks({
240 role: "client",
241 excludeSubjects: ["owner-bootstrap"],
242 })
243 .pipe(
244 Effect.mapError(
245 (cause) =>
246 new AuthError({
247 message: "Failed to load pairing links.",
248 cause,
249 }),
250 ),
251 );
252
253 const revokePairingLink: ServerAuthShape["revokePairingLink"] = (id) =>
254 authControlPlane.revokePairingLink(id).pipe(
255 Effect.mapError(
256 (cause) =>
257 new AuthError({
258 message: "Failed to revoke pairing link.",
259 cause,
260 }),
261 ),
262 );
263
264 const listClientSessions: ServerAuthShape["listClientSessions"] = (currentSessionId) =>
265 authControlPlane.listSessions().pipe(
266 Effect.mapError(
267 (cause) =>
268 new AuthError({
269 message: "Failed to load paired clients.",
270 cause,
271 }),
272 ),
273 Effect.map((clientSessions) =>
274 clientSessions.map(
275 (clientSession): AuthClientSession => ({
276 ...clientSession,
277 current: clientSession.sessionId === currentSessionId,
278 }),
279 ),
280 ),
281 );
282
283 const revokeClientSession: ServerAuthShape["revokeClientSession"] = (
284 currentSessionId,
285 targetSessionId,
286 ) =>
287 Effect.gen(function* () {
288 if (currentSessionId === targetSessionId) {
289 return yield* new AuthError({
290 message: "Use revoke other clients to keep the current owner session active.",
291 status: 403,
292 });
293 }
294 return yield* authControlPlane.revokeSession(targetSessionId).pipe(
295 Effect.mapError(
296 (cause) =>
297 new AuthError({
298 message: "Failed to revoke client session.",
299 cause,
300 }),
301 ),
302 );
303 });
304
305 const revokeOtherClientSessions: ServerAuthShape["revokeOtherClientSessions"] = (
306 currentSessionId,
307 ) =>
308 authControlPlane.revokeOtherSessionsExcept(currentSessionId).pipe(
309 Effect.mapError(
310 (cause) =>
311 new AuthError({
312 message: "Failed to revoke other client sessions.",
313 cause,
314 }),
315 ),
316 );
317
318 const issueStartupPairingUrl: ServerAuthShape["issueStartupPairingUrl"] = (baseUrl) =>
319 issuePairingCredential({ role: "owner" }).pipe(
320 Effect.map((issued) => {
321 const url = new URL(baseUrl);
322 url.pathname = "/pair";
323 url.searchParams.delete("token");
324 url.hash = new URLSearchParams([["token", issued.credential]]).toString();
325 return url.toString();
326 }),
327 );
328
329 const issueWebSocketToken: ServerAuthShape["issueWebSocketToken"] = (session) =>
330 sessions.issueWebSocketToken(session.sessionId).pipe(
331 Effect.mapError(
332 (cause) =>
333 new AuthError({
334 message: "Failed to issue websocket token.",
335 cause,
336 }),
337 ),
338 Effect.map(
339 (issued) =>
340 ({
341 token: issued.token,
342 expiresAt: DateTime.toUtc(issued.expiresAt),
343 }) satisfies AuthWebSocketTokenResult,
344 ),
345 );
346
347 const authenticateWebSocketUpgrade: ServerAuthShape["authenticateWebSocketUpgrade"] = (request) =>
348 Effect.gen(function* () {
349 const requestUrl = HttpServerRequest.toURL(request);
350 if (Option.isSome(requestUrl)) {
351 const websocketToken = requestUrl.value.searchParams.get(WEBSOCKET_TOKEN_QUERY_PARAM);
352 if (websocketToken && websocketToken.trim().length > 0) {
353 return yield* sessions.verifyWebSocketToken(websocketToken).pipe(
354 Effect.map((session) => ({
355 sessionId: session.sessionId,
356 subject: session.subject,
357 method: session.method,
358 role: session.role,
359 ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}),
360 })),
361 Effect.mapError(
362 (cause) =>
363 new AuthError({
364 message: "Unauthorized request.",
365 status: 401,
366 cause,
367 }),
368 ),
369 );
370 }
371 }
372
373 return yield* authenticateRequest(request);
374 });
375
376 return {
377 getDescriptor: () => Effect.succeed(descriptor),
378 getSessionState,
379 exchangeBootstrapCredential,
380 exchangeBootstrapCredentialForBearerSession,
381 issuePairingCredential,
382 listPairingLinks,
383 revokePairingLink,
384 listClientSessions,
385 revokeClientSession,
386 revokeOtherClientSessions,
387 authenticateHttpRequest: authenticateRequest,
388 authenticateWebSocketUpgrade,
389 issueWebSocketToken,
390 issueStartupPairingUrl,
391 } satisfies ServerAuthShape;
392});
393
394export const ServerAuthLive = Layer.effect(ServerAuth, makeServerAuth).pipe(
395 Layer.provideMerge(AuthControlPlaneLive),
396 Layer.provideMerge(AuthCoreLive),
397 Layer.provideMerge(ServerAuthPolicyLive),
398);
apps/server/src/auth/Layers/ServerAuthPolicy.test.ts deleted-115
...@@ -1,115 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import type { ServerConfigShape } from "../../config.ts";
7import { ServerConfig } from "../../config.ts";
8import { ServerAuthPolicy } from "../Services/ServerAuthPolicy.ts";
9import { ServerAuthPolicyLive } from "./ServerAuthPolicy.ts";
10
11const makeServerAuthPolicyLayer = (overrides?: Partial<ServerConfigShape>) =>
12 ServerAuthPolicyLive.pipe(
13 Layer.provide(
14 Layer.effect(
15 ServerConfig,
16 Effect.gen(function* () {
17 const config = yield* ServerConfig;
18 return {
19 ...config,
20 ...overrides,
21 } satisfies ServerConfigShape;
22 }),
23 ).pipe(
24 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-policy-test-" })),
25 ),
26 ),
27 );
28
29it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => {
30 it.effect("uses desktop-managed-local policy for desktop mode", () =>
31 Effect.gen(function* () {
32 const policy = yield* ServerAuthPolicy;
33 const descriptor = yield* policy.getDescriptor();
34
35 expect(descriptor.policy).toBe("desktop-managed-local");
36 expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]);
37 expect(descriptor.sessionCookieName).toBe("t3_session_3773");
38 }).pipe(
39 Effect.provide(
40 makeServerAuthPolicyLayer({
41 mode: "desktop",
42 port: 3773,
43 }),
44 ),
45 ),
46 );
47
48 it.effect("uses remote-reachable policy for desktop mode when bound beyond loopback", () =>
49 Effect.gen(function* () {
50 const policy = yield* ServerAuthPolicy;
51 const descriptor = yield* policy.getDescriptor();
52
53 expect(descriptor.policy).toBe("remote-reachable");
54 expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap", "one-time-token"]);
55 }).pipe(
56 Effect.provide(
57 makeServerAuthPolicyLayer({
58 mode: "desktop",
59 host: "0.0.0.0",
60 }),
61 ),
62 ),
63 );
64
65 it.effect("uses loopback-browser policy for loopback web hosts", () =>
66 Effect.gen(function* () {
67 const policy = yield* ServerAuthPolicy;
68 const descriptor = yield* policy.getDescriptor();
69
70 expect(descriptor.policy).toBe("loopback-browser");
71 expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]);
72 expect(descriptor.sessionCookieName).toBe("t3_session");
73 }).pipe(
74 Effect.provide(
75 makeServerAuthPolicyLayer({
76 mode: "web",
77 host: "127.0.0.1",
78 }),
79 ),
80 ),
81 );
82
83 it.effect("uses remote-reachable policy for wildcard web hosts", () =>
84 Effect.gen(function* () {
85 const policy = yield* ServerAuthPolicy;
86 const descriptor = yield* policy.getDescriptor();
87
88 expect(descriptor.policy).toBe("remote-reachable");
89 expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]);
90 }).pipe(
91 Effect.provide(
92 makeServerAuthPolicyLayer({
93 mode: "web",
94 host: "0.0.0.0",
95 }),
96 ),
97 ),
98 );
99
100 it.effect("uses remote-reachable policy for non-loopback web hosts", () =>
101 Effect.gen(function* () {
102 const policy = yield* ServerAuthPolicy;
103 const descriptor = yield* policy.getDescriptor();
104
105 expect(descriptor.policy).toBe("remote-reachable");
106 }).pipe(
107 Effect.provide(
108 makeServerAuthPolicyLayer({
109 mode: "web",
110 host: "192.168.1.50",
111 }),
112 ),
113 ),
114 );
115});
apps/server/src/auth/Layers/ServerAuthPolicy.ts deleted-45
...@@ -1,45 +0,0 @@
1import type { ServerAuthDescriptor } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4
5import { ServerConfig } from "../../config.ts";
6import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts";
7import { resolveSessionCookieName } from "../utils.ts";
8import { isLoopbackHost, isWildcardHost } from "../../startupAccess.ts";
9
10export const makeServerAuthPolicy = Effect.gen(function* () {
11 const config = yield* ServerConfig;
12 const isRemoteReachable = isWildcardHost(config.host) || !isLoopbackHost(config.host);
13
14 const policy =
15 config.mode === "desktop"
16 ? isRemoteReachable
17 ? "remote-reachable"
18 : "desktop-managed-local"
19 : isRemoteReachable
20 ? "remote-reachable"
21 : "loopback-browser";
22
23 const bootstrapMethods: ServerAuthDescriptor["bootstrapMethods"] =
24 policy === "desktop-managed-local"
25 ? ["desktop-bootstrap"]
26 : config.mode === "desktop" && policy === "remote-reachable"
27 ? ["desktop-bootstrap", "one-time-token"]
28 : ["one-time-token"];
29
30 const descriptor: ServerAuthDescriptor = {
31 policy,
32 bootstrapMethods,
33 sessionMethods: ["browser-session-cookie", "bearer-session-token"],
34 sessionCookieName: resolveSessionCookieName({
35 mode: config.mode,
36 port: config.port,
37 }),
38 };
39
40 return {
41 getDescriptor: () => Effect.succeed(descriptor),
42 } satisfies ServerAuthPolicyShape;
43});
44
45export const ServerAuthPolicyLive = Layer.effect(ServerAuthPolicy, makeServerAuthPolicy);
apps/server/src/auth/Layers/ServerSecretStore.test.ts deleted-268
...@@ -1,268 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as Cause from "effect/Cause";
4import * as Deferred from "effect/Deferred";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Ref from "effect/Ref";
9import * as PlatformError from "effect/PlatformError";
10
11import { ServerConfig } from "../../config.ts";
12import { SecretStoreError, ServerSecretStore } from "../Services/ServerSecretStore.ts";
13import { ServerSecretStoreLive } from "./ServerSecretStore.ts";
14
15const makeServerConfigLayer = () =>
16 ServerConfig.layerTest(process.cwd(), { prefix: "t3-secret-store-test-" });
17
18const makeServerSecretStoreLayer = () =>
19 Layer.provide(ServerSecretStoreLive, makeServerConfigLayer());
20
21const PermissionDeniedFileSystemLayer = Layer.effect(
22 FileSystem.FileSystem,
23 Effect.gen(function* () {
24 const fileSystem = yield* FileSystem.FileSystem;
25
26 return {
27 ...fileSystem,
28 readFile: (path) =>
29 Effect.fail(
30 PlatformError.systemError({
31 _tag: "PermissionDenied",
32 module: "FileSystem",
33 method: "readFile",
34 pathOrDescriptor: path,
35 description: "Permission denied while reading secret file.",
36 }),
37 ),
38 } satisfies FileSystem.FileSystem;
39 }),
40).pipe(Layer.provide(NodeServices.layer));
41
42const makePermissionDeniedSecretStoreLayer = () =>
43 ServerSecretStoreLive.pipe(
44 Layer.provide(makeServerConfigLayer()),
45 Layer.provideMerge(PermissionDeniedFileSystemLayer),
46 );
47
48const RenameFailureFileSystemLayer = Layer.effect(
49 FileSystem.FileSystem,
50 Effect.gen(function* () {
51 const fileSystem = yield* FileSystem.FileSystem;
52
53 return {
54 ...fileSystem,
55 rename: (from, to) =>
56 Effect.fail(
57 PlatformError.systemError({
58 _tag: "PermissionDenied",
59 module: "FileSystem",
60 method: "rename",
61 pathOrDescriptor: `${String(from)} -> ${String(to)}`,
62 description: "Permission denied while persisting secret file.",
63 }),
64 ),
65 } satisfies FileSystem.FileSystem;
66 }),
67).pipe(Layer.provide(NodeServices.layer));
68
69const makeRenameFailureSecretStoreLayer = () =>
70 ServerSecretStoreLive.pipe(
71 Layer.provide(makeServerConfigLayer()),
72 Layer.provideMerge(RenameFailureFileSystemLayer),
73 );
74
75const RemoveFailureFileSystemLayer = Layer.effect(
76 FileSystem.FileSystem,
77 Effect.gen(function* () {
78 const fileSystem = yield* FileSystem.FileSystem;
79
80 return {
81 ...fileSystem,
82 remove: (path, options) =>
83 Effect.fail(
84 PlatformError.systemError({
85 _tag: "PermissionDenied",
86 module: "FileSystem",
87 method: "remove",
88 pathOrDescriptor: String(path),
89 description: `Permission denied while removing secret file.${options ? " options-set" : ""}`,
90 }),
91 ),
92 } satisfies FileSystem.FileSystem;
93 }),
94).pipe(Layer.provide(NodeServices.layer));
95
96const makeRemoveFailureSecretStoreLayer = () =>
97 ServerSecretStoreLive.pipe(
98 Layer.provide(makeServerConfigLayer()),
99 Layer.provideMerge(RemoveFailureFileSystemLayer),
100 );
101
102const ConcurrentReadMissFileSystemLayer = Layer.effect(
103 FileSystem.FileSystem,
104 Effect.gen(function* () {
105 const fileSystem = yield* FileSystem.FileSystem;
106 const readCountRef = yield* Ref.make(0);
107 const readBarrier = yield* Deferred.make<void>();
108
109 return {
110 ...fileSystem,
111 readFile: (path) =>
112 String(path).endsWith("/session-signing-key.bin")
113 ? Ref.updateAndGet(readCountRef, (count) => count + 1).pipe(
114 Effect.flatMap((count) => {
115 if (count > 2) {
116 return fileSystem.readFile(path);
117 }
118 return Effect.gen(function* () {
119 if (count === 2) {
120 yield* Deferred.succeed(readBarrier, void 0);
121 }
122 yield* Deferred.await(readBarrier);
123 return yield* Effect.failCause(
124 Cause.fail(
125 PlatformError.systemError({
126 _tag: "NotFound",
127 module: "FileSystem",
128 method: "readFile",
129 pathOrDescriptor: String(path),
130 description: "Secret file does not exist yet.",
131 }),
132 ),
133 );
134 });
135 }),
136 )
137 : fileSystem.readFile(path),
138 } satisfies FileSystem.FileSystem;
139 }),
140).pipe(Layer.provide(NodeServices.layer));
141
142const makeConcurrentCreateSecretStoreLayer = () =>
143 ServerSecretStoreLive.pipe(
144 Layer.provide(makeServerConfigLayer()),
145 Layer.provideMerge(ConcurrentReadMissFileSystemLayer),
146 );
147
148it.layer(NodeServices.layer)("ServerSecretStoreLive", (it) => {
149 it.effect("returns null when a secret file does not exist", () =>
150 Effect.gen(function* () {
151 const secretStore = yield* ServerSecretStore;
152
153 const secret = yield* secretStore.get("missing-secret");
154
155 expect(secret).toBeNull();
156 }).pipe(Effect.provide(makeServerSecretStoreLayer())),
157 );
158
159 it.effect("reuses an existing secret instead of regenerating it", () =>
160 Effect.gen(function* () {
161 const secretStore = yield* ServerSecretStore;
162
163 const first = yield* secretStore.getOrCreateRandom("session-signing-key", 32);
164 const second = yield* secretStore.getOrCreateRandom("session-signing-key", 32);
165
166 expect(Array.from(second)).toEqual(Array.from(first));
167 }).pipe(Effect.provide(makeServerSecretStoreLayer())),
168 );
169
170 it.effect("returns the persisted secret when concurrent creators race", () =>
171 Effect.gen(function* () {
172 const secretStore = yield* ServerSecretStore;
173
174 const [first, second] = yield* Effect.all(
175 [
176 secretStore.getOrCreateRandom("session-signing-key", 32),
177 secretStore.getOrCreateRandom("session-signing-key", 32),
178 ],
179 { concurrency: "unbounded" },
180 );
181 const persisted = yield* secretStore.get("session-signing-key");
182
183 expect(persisted).not.toBeNull();
184 expect(Array.from(first)).toEqual(Array.from(persisted ?? new Uint8Array()));
185 expect(Array.from(second)).toEqual(Array.from(persisted ?? new Uint8Array()));
186 }).pipe(Effect.provide(makeConcurrentCreateSecretStoreLayer())),
187 );
188
189 it.effect("uses restrictive permissions for the secret directory and files", () =>
190 Effect.gen(function* () {
191 const chmodCalls: Array<{ readonly path: string; readonly mode: number }> = [];
192 const recordingFileSystemLayer = Layer.effect(
193 FileSystem.FileSystem,
194 Effect.gen(function* () {
195 const fileSystem = yield* FileSystem.FileSystem;
196
197 return {
198 ...fileSystem,
199 makeDirectory: () => Effect.void,
200 writeFile: () => Effect.void,
201 rename: () => Effect.void,
202 chmod: (path, mode) =>
203 Effect.sync(() => {
204 chmodCalls.push({ path: String(path), mode });
205 }),
206 } satisfies FileSystem.FileSystem;
207 }),
208 ).pipe(Layer.provide(NodeServices.layer));
209
210 const secretStore = yield* Effect.service(ServerSecretStore).pipe(
211 Effect.provide(
212 ServerSecretStoreLive.pipe(
213 Layer.provide(makeServerConfigLayer()),
214 Layer.provideMerge(recordingFileSystemLayer),
215 ),
216 ),
217 );
218
219 yield* secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3]));
220
221 expect(chmodCalls.some((call) => call.mode === 0o700 && call.path.endsWith("/secrets"))).toBe(
222 true,
223 );
224 expect(chmodCalls.filter((call) => call.mode === 0o600).length).toBeGreaterThanOrEqual(2);
225 }).pipe(Effect.provide(NodeServices.layer)),
226 );
227
228 it.effect("propagates read failures other than missing-file errors", () =>
229 Effect.gen(function* () {
230 const secretStore = yield* ServerSecretStore;
231
232 const error = yield* Effect.flip(secretStore.getOrCreateRandom("session-signing-key", 32));
233
234 expect(error).toBeInstanceOf(SecretStoreError);
235 expect(error.message).toContain("Failed to read secret session-signing-key.");
236 expect(error.cause).toBeInstanceOf(PlatformError.PlatformError);
237 expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied");
238 }).pipe(Effect.provide(makePermissionDeniedSecretStoreLayer())),
239 );
240
241 it.effect("propagates write failures instead of treating them as success", () =>
242 Effect.gen(function* () {
243 const secretStore = yield* ServerSecretStore;
244
245 const error = yield* Effect.flip(
246 secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])),
247 );
248
249 expect(error).toBeInstanceOf(SecretStoreError);
250 expect(error.message).toContain("Failed to persist secret session-signing-key.");
251 expect(error.cause).toBeInstanceOf(PlatformError.PlatformError);
252 expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied");
253 }).pipe(Effect.provide(makeRenameFailureSecretStoreLayer())),
254 );
255
256 it.effect("propagates remove failures other than missing-file errors", () =>
257 Effect.gen(function* () {
258 const secretStore = yield* ServerSecretStore;
259
260 const error = yield* Effect.flip(secretStore.remove("session-signing-key"));
261
262 expect(error).toBeInstanceOf(SecretStoreError);
263 expect(error.message).toContain("Failed to remove secret session-signing-key.");
264 expect(error.cause).toBeInstanceOf(PlatformError.PlatformError);
265 expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied");
266 }).pipe(Effect.provide(makeRemoveFailureSecretStoreLayer())),
267 );
268});
apps/server/src/auth/Layers/ServerSecretStore.ts deleted-152
...@@ -1,152 +0,0 @@
1import * as Crypto from "node:crypto";
2
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Path from "effect/Path";
7import * as Predicate from "effect/Predicate";
8import * as PlatformError from "effect/PlatformError";
9
10import { ServerConfig } from "../../config.ts";
11import {
12 SecretStoreError,
13 ServerSecretStore,
14 type ServerSecretStoreShape,
15} from "../Services/ServerSecretStore.ts";
16
17export const makeServerSecretStore = Effect.gen(function* () {
18 const fileSystem = yield* FileSystem.FileSystem;
19 const path = yield* Path.Path;
20 const serverConfig = yield* ServerConfig;
21
22 yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true });
23 yield* fileSystem.chmod(serverConfig.secretsDir, 0o700).pipe(
24 Effect.mapError(
25 (cause) =>
26 new SecretStoreError({
27 message: `Failed to secure secrets directory ${serverConfig.secretsDir}.`,
28 cause,
29 }),
30 ),
31 );
32
33 const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`);
34
35 const isPlatformError = (u: unknown): u is PlatformError.PlatformError =>
36 Predicate.isTagged(u, "PlatformError");
37
38 const get: ServerSecretStoreShape["get"] = (name) =>
39 fileSystem.readFile(resolveSecretPath(name)).pipe(
40 Effect.map((bytes) => Uint8Array.from(bytes)),
41 Effect.catch((cause) =>
42 cause.reason._tag === "NotFound"
43 ? Effect.succeed(null)
44 : Effect.fail(
45 new SecretStoreError({
46 message: `Failed to read secret ${name}.`,
47 cause,
48 }),
49 ),
50 ),
51 );
52
53 const set: ServerSecretStoreShape["set"] = (name, value) => {
54 const secretPath = resolveSecretPath(name);
55 const tempPath = `${secretPath}.${Crypto.randomUUID()}.tmp`;
56 return Effect.gen(function* () {
57 yield* fileSystem.writeFile(tempPath, value);
58 yield* fileSystem.chmod(tempPath, 0o600);
59 yield* fileSystem.rename(tempPath, secretPath);
60 yield* fileSystem.chmod(secretPath, 0o600);
61 }).pipe(
62 Effect.catch((cause) =>
63 fileSystem.remove(tempPath).pipe(
64 Effect.ignore,
65 Effect.flatMap(() =>
66 Effect.fail(
67 new SecretStoreError({
68 message: `Failed to persist secret ${name}.`,
69 cause,
70 }),
71 ),
72 ),
73 ),
74 ),
75 );
76 };
77
78 const create: ServerSecretStoreShape["set"] = (name, value) => {
79 const secretPath = resolveSecretPath(name);
80 return Effect.scoped(
81 Effect.gen(function* () {
82 const file = yield* fileSystem.open(secretPath, {
83 flag: "wx",
84 mode: 0o600,
85 });
86 yield* file.writeAll(value);
87 yield* file.sync;
88 yield* fileSystem.chmod(secretPath, 0o600);
89 }),
90 ).pipe(
91 Effect.mapError(
92 (cause) =>
93 new SecretStoreError({
94 message: `Failed to persist secret ${name}.`,
95 cause,
96 }),
97 ),
98 );
99 };
100
101 const getOrCreateRandom: ServerSecretStoreShape["getOrCreateRandom"] = (name, bytes) =>
102 get(name).pipe(
103 Effect.flatMap((existing) => {
104 if (existing) {
105 return Effect.succeed(existing);
106 }
107
108 const generated = Crypto.randomBytes(bytes);
109 return create(name, generated).pipe(
110 Effect.as(Uint8Array.from(generated)),
111 Effect.catchTag("SecretStoreError", (error) =>
112 isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists"
113 ? get(name).pipe(
114 Effect.flatMap((created) =>
115 created !== null
116 ? Effect.succeed(created)
117 : Effect.fail(
118 new SecretStoreError({
119 message: `Failed to read secret ${name} after concurrent creation.`,
120 }),
121 ),
122 ),
123 )
124 : Effect.fail(error),
125 ),
126 );
127 }),
128 );
129
130 const remove: ServerSecretStoreShape["remove"] = (name) =>
131 fileSystem.remove(resolveSecretPath(name)).pipe(
132 Effect.catch((cause) =>
133 cause.reason._tag === "NotFound"
134 ? Effect.void
135 : Effect.fail(
136 new SecretStoreError({
137 message: `Failed to remove secret ${name}.`,
138 cause,
139 }),
140 ),
141 ),
142 );
143
144 return {
145 get,
146 set,
147 getOrCreateRandom,
148 remove,
149 } satisfies ServerSecretStoreShape;
150});
151
152export const ServerSecretStoreLive = Layer.effect(ServerSecretStore, makeServerSecretStore);
apps/server/src/auth/Layers/SessionCredentialService.test.ts deleted-193
...@@ -1,193 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as TestClock from "effect/testing/TestClock";
7
8import type { ServerConfigShape } from "../../config.ts";
9import { ServerConfig } from "../../config.ts";
10import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
11import { SessionCredentialService } from "../Services/SessionCredentialService.ts";
12import { ServerSecretStoreLive } from "./ServerSecretStore.ts";
13import { SessionCredentialServiceLive } from "./SessionCredentialService.ts";
14
15const makeServerConfigLayer = (
16 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
17) =>
18 Layer.effect(
19 ServerConfig,
20 Effect.gen(function* () {
21 const config = yield* ServerConfig;
22 return {
23 ...config,
24 ...overrides,
25 } satisfies ServerConfigShape;
26 }),
27 ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" })));
28
29const makeSessionCredentialLayer = (
30 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
31) =>
32 SessionCredentialServiceLive.pipe(
33 Layer.provide(SqlitePersistenceMemory),
34 Layer.provide(ServerSecretStoreLive),
35 Layer.provide(makeServerConfigLayer(overrides)),
36 );
37
38it.layer(NodeServices.layer)("SessionCredentialServiceLive", (it) => {
39 it.effect("issues and verifies signed browser session tokens", () =>
40 Effect.gen(function* () {
41 const sessions = yield* SessionCredentialService;
42 const issued = yield* sessions.issue({
43 subject: "desktop-bootstrap",
44 role: "owner",
45 client: {
46 label: "Desktop app",
47 deviceType: "desktop",
48 os: "macOS",
49 browser: "Electron",
50 ipAddress: "127.0.0.1",
51 },
52 });
53 const verified = yield* sessions.verify(issued.token);
54
55 expect(verified.method).toBe("browser-session-cookie");
56 expect(verified.subject).toBe("desktop-bootstrap");
57 expect(verified.role).toBe("owner");
58 expect(verified.client.label).toBe("Desktop app");
59 expect(verified.client.browser).toBe("Electron");
60 expect(verified.expiresAt?.toString()).toBe(issued.expiresAt.toString());
61 }).pipe(Effect.provide(makeSessionCredentialLayer())),
62 );
63 it.effect("rejects malformed session tokens", () =>
64 Effect.gen(function* () {
65 const sessions = yield* SessionCredentialService;
66 const error = yield* Effect.flip(sessions.verify("not-a-session-token"));
67
68 expect(error._tag).toBe("SessionCredentialError");
69 expect(error.message).toContain("Malformed session token");
70 }).pipe(Effect.provide(makeSessionCredentialLayer())),
71 );
72 it.effect("verifies session tokens against the Effect clock", () =>
73 Effect.gen(function* () {
74 const sessions = yield* SessionCredentialService;
75 const issued = yield* sessions.issue({
76 method: "bearer-session-token",
77 subject: "test-clock",
78 });
79 const verified = yield* sessions.verify(issued.token);
80
81 expect(verified.method).toBe("bearer-session-token");
82 expect(verified.subject).toBe("test-clock");
83 expect(verified.role).toBe("client");
84 }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))),
85 );
86
87 it.effect("rejects websocket tokens once the parent session has expired", () =>
88 Effect.gen(function* () {
89 const sessions = yield* SessionCredentialService;
90 const issued = yield* sessions.issue({
91 method: "bearer-session-token",
92 subject: "short-lived",
93 ttl: Duration.seconds(1),
94 });
95 const websocket = yield* sessions.issueWebSocketToken(issued.sessionId);
96
97 yield* TestClock.adjust(Duration.seconds(2));
98
99 const error = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token));
100 expect(error.message).toContain("expired");
101 }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))),
102 );
103
104 it.effect("lists active sessions, tracks connectivity, and revokes other sessions", () =>
105 Effect.gen(function* () {
106 const sessions = yield* SessionCredentialService;
107 const owner = yield* sessions.issue({
108 subject: "desktop-bootstrap",
109 role: "owner",
110 client: {
111 label: "Desktop app",
112 deviceType: "desktop",
113 os: "macOS",
114 browser: "Electron",
115 },
116 });
117 const client = yield* sessions.issue({
118 subject: "one-time-token",
119 role: "client",
120 client: {
121 label: "Julius iPhone",
122 deviceType: "mobile",
123 os: "iOS",
124 browser: "Safari",
125 ipAddress: "192.168.1.88",
126 },
127 });
128
129 yield* sessions.markConnected(client.sessionId);
130 const beforeRevoke = yield* sessions.listActive();
131 const revokedCount = yield* sessions.revokeAllExcept(owner.sessionId);
132 const afterRevoke = yield* sessions.listActive();
133 const revokedClient = yield* Effect.flip(sessions.verify(client.token));
134
135 expect(beforeRevoke).toHaveLength(2);
136 expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.connected).toBe(
137 true,
138 );
139 expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.client.label).toBe(
140 "Julius iPhone",
141 );
142 expect(
143 beforeRevoke.find((entry) => entry.sessionId === owner.sessionId)?.client.deviceType,
144 ).toBe("desktop");
145 expect(revokedCount).toBe(1);
146 expect(afterRevoke).toHaveLength(1);
147 expect(afterRevoke[0]?.sessionId).toBe(owner.sessionId);
148 expect(revokedClient.message).toContain("revoked");
149 }).pipe(Effect.provide(makeSessionCredentialLayer())),
150 );
151
152 it.effect("persists lastConnectedAt on first connect and updates it after reconnect", () =>
153 Effect.gen(function* () {
154 const sessions = yield* SessionCredentialService;
155 const issued = yield* sessions.issue({
156 subject: "reconnect-test",
157 method: "bearer-session-token",
158 });
159
160 const beforeConnect = yield* sessions.listActive();
161 expect(beforeConnect[0]?.lastConnectedAt).toBeNull();
162
163 yield* TestClock.adjust(Duration.seconds(1));
164 yield* sessions.markConnected(issued.sessionId);
165 const firstConnect = yield* sessions.listActive();
166 const firstConnectedAt = firstConnect[0]?.lastConnectedAt;
167
168 expect(firstConnect[0]?.connected).toBe(true);
169 expect(firstConnectedAt).not.toBeNull();
170
171 yield* TestClock.adjust(Duration.seconds(1));
172 yield* sessions.markConnected(issued.sessionId);
173 const stillConnected = yield* sessions.listActive();
174
175 expect(stillConnected[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString());
176
177 yield* sessions.markDisconnected(issued.sessionId);
178 yield* sessions.markDisconnected(issued.sessionId);
179 const afterDisconnect = yield* sessions.listActive();
180
181 expect(afterDisconnect[0]?.connected).toBe(false);
182 expect(afterDisconnect[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString());
183
184 yield* TestClock.adjust(Duration.seconds(1));
185 yield* sessions.markConnected(issued.sessionId);
186 const afterReconnect = yield* sessions.listActive();
187
188 expect(afterReconnect[0]?.connected).toBe(true);
189 expect(afterReconnect[0]?.lastConnectedAt).not.toBeNull();
190 expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(firstConnectedAt?.toString());
191 }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))),
192 );
193});
apps/server/src/auth/Layers/SessionCredentialService.ts deleted-530
...@@ -1,530 +0,0 @@
1import { AuthSessionId, type AuthClientMetadata, type AuthClientSession } from "@t3tools/contracts";
2import * as Clock from "effect/Clock";
3import * as Crypto from "effect/Crypto";
4import * as DateTime from "effect/DateTime";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as PubSub from "effect/PubSub";
9import * as Ref from "effect/Ref";
10import * as Schema from "effect/Schema";
11import * as Stream from "effect/Stream";
12import * as Option from "effect/Option";
13
14import { ServerConfig } from "../../config.ts";
15import { AuthSessionRepositoryLive } from "../../persistence/Layers/AuthSessions.ts";
16import { AuthSessionRepository } from "../../persistence/Services/AuthSessions.ts";
17import { ServerSecretStore } from "../Services/ServerSecretStore.ts";
18import {
19 SessionCredentialError,
20 SessionCredentialService,
21 type IssuedSession,
22 type SessionCredentialChange,
23 type SessionCredentialServiceShape,
24 type VerifiedSession,
25} from "../Services/SessionCredentialService.ts";
26import {
27 base64UrlDecodeUtf8,
28 base64UrlEncode,
29 resolveSessionCookieName,
30 signPayload,
31 timingSafeEqualBase64Url,
32} from "../utils.ts";
33
34const SIGNING_SECRET_NAME = "server-signing-key";
35const DEFAULT_SESSION_TTL = Duration.days(30);
36const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5);
37
38const SessionClaims = Schema.Struct({
39 v: Schema.Literal(1),
40 kind: Schema.Literal("session"),
41 sid: AuthSessionId,
42 sub: Schema.String,
43 role: Schema.Literals(["owner", "client"]),
44 method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]),
45 iat: Schema.Number,
46 exp: Schema.Number,
47});
48type SessionClaims = typeof SessionClaims.Type;
49
50const WebSocketClaims = Schema.Struct({
51 v: Schema.Literal(1),
52 kind: Schema.Literal("websocket"),
53 sid: AuthSessionId,
54 iat: Schema.Number,
55 exp: Schema.Number,
56});
57type WebSocketClaims = typeof WebSocketClaims.Type;
58
59const decodeSessionClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(SessionClaims));
60const decodeWebSocketClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(WebSocketClaims));
61
62function createDefaultClientMetadata(): AuthClientMetadata {
63 return {
64 deviceType: "unknown",
65 };
66}
67
68function toClientMetadata(record: {
69 readonly label: string | null;
70 readonly ipAddress: string | null;
71 readonly userAgent: string | null;
72 readonly deviceType: AuthClientMetadata["deviceType"];
73 readonly os: string | null;
74 readonly browser: string | null;
75}): AuthClientMetadata {
76 return {
77 ...(record.label ? { label: record.label } : {}),
78 ...(record.ipAddress ? { ipAddress: record.ipAddress } : {}),
79 ...(record.userAgent ? { userAgent: record.userAgent } : {}),
80 deviceType: record.deviceType,
81 ...(record.os ? { os: record.os } : {}),
82 ...(record.browser ? { browser: record.browser } : {}),
83 };
84}
85
86function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthClientSession {
87 return {
88 ...input,
89 current: false,
90 };
91}
92
93export const makeSessionCredentialService = Effect.gen(function* () {
94 const crypto = yield* Crypto.Crypto;
95 const serverConfig = yield* ServerConfig;
96 const secretStore = yield* ServerSecretStore;
97 const authSessions = yield* AuthSessionRepository;
98 const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32);
99 const connectedSessionsRef = yield* Ref.make(new Map<string, number>());
100 const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>();
101 const cookieName = resolveSessionCookieName({
102 mode: serverConfig.mode,
103 port: serverConfig.port,
104 });
105
106 const toSessionCredentialError = (message: string) => (cause: unknown) =>
107 new SessionCredentialError({
108 message,
109 cause,
110 });
111
112 const emitUpsert = (clientSession: AuthClientSession) =>
113 PubSub.publish(changesPubSub, {
114 type: "clientUpserted",
115 clientSession,
116 }).pipe(Effect.asVoid);
117
118 const emitRemoved = (sessionId: AuthSessionId) =>
119 PubSub.publish(changesPubSub, {
120 type: "clientRemoved",
121 sessionId,
122 }).pipe(Effect.asVoid);
123
124 const loadActiveSession = (sessionId: AuthSessionId) =>
125 Effect.gen(function* () {
126 const row = yield* authSessions.getById({ sessionId });
127 if (Option.isNone(row) || row.value.revokedAt !== null) {
128 return Option.none<AuthClientSession>();
129 }
130
131 const connectedSessions = yield* Ref.get(connectedSessionsRef);
132 return Option.some(
133 toAuthClientSession({
134 sessionId: row.value.sessionId,
135 subject: row.value.subject,
136 role: row.value.role,
137 method: row.value.method,
138 client: toClientMetadata(row.value.client),
139 issuedAt: row.value.issuedAt,
140 expiresAt: row.value.expiresAt,
141 lastConnectedAt: row.value.lastConnectedAt,
142 connected: connectedSessions.has(row.value.sessionId),
143 }),
144 );
145 });
146
147 const markConnected: SessionCredentialServiceShape["markConnected"] = (sessionId) =>
148 Ref.modify(connectedSessionsRef, (current) => {
149 const next = new Map(current);
150 const wasDisconnected = !next.has(sessionId);
151 next.set(sessionId, (next.get(sessionId) ?? 0) + 1);
152 return [wasDisconnected, next] as const;
153 }).pipe(
154 Effect.flatMap((wasDisconnected) =>
155 wasDisconnected
156 ? DateTime.now.pipe(
157 Effect.flatMap((lastConnectedAt) =>
158 authSessions.setLastConnectedAt({
159 sessionId,
160 lastConnectedAt,
161 }),
162 ),
163 )
164 : Effect.void,
165 ),
166 Effect.flatMap(() => loadActiveSession(sessionId)),
167 Effect.flatMap((session) =>
168 Option.isSome(session) ? emitUpsert(session.value) : Effect.void,
169 ),
170 Effect.catchCause((cause) =>
171 Effect.logError("Failed to publish connected-session auth update.").pipe(
172 Effect.annotateLogs({
173 sessionId,
174 cause,
175 }),
176 ),
177 ),
178 );
179
180 const markDisconnected: SessionCredentialServiceShape["markDisconnected"] = (sessionId) =>
181 Ref.update(connectedSessionsRef, (current) => {
182 const next = new Map(current);
183 const remaining = (next.get(sessionId) ?? 0) - 1;
184 if (remaining > 0) {
185 next.set(sessionId, remaining);
186 } else {
187 next.delete(sessionId);
188 }
189 return next;
190 }).pipe(
191 Effect.flatMap(() => loadActiveSession(sessionId)),
192 Effect.flatMap((session) =>
193 Option.isSome(session) ? emitUpsert(session.value) : Effect.void,
194 ),
195 Effect.catchCause((cause) =>
196 Effect.logError("Failed to publish disconnected-session auth update.").pipe(
197 Effect.annotateLogs({
198 sessionId,
199 cause,
200 }),
201 ),
202 ),
203 );
204
205 const encodeClaims = Schema.encodeEffect(Schema.fromJsonString(SessionClaims));
206 const issue: SessionCredentialServiceShape["issue"] = (input) =>
207 Effect.gen(function* () {
208 const sessionId = AuthSessionId.make(yield* crypto.randomUUIDv4);
209 const issuedAt = yield* DateTime.now;
210 const expiresAt = DateTime.add(issuedAt, {
211 milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL),
212 });
213 const claims: SessionClaims = {
214 v: 1,
215 kind: "session",
216 sid: sessionId,
217 sub: input?.subject ?? "browser",
218 role: input?.role ?? "client",
219 method: input?.method ?? "browser-session-cookie",
220 iat: issuedAt.epochMilliseconds,
221 exp: expiresAt.epochMilliseconds,
222 };
223
224 const encodedPayload = yield* encodeClaims(claims).pipe(
225 Effect.map(base64UrlEncode),
226 Effect.mapError(
227 (cause) => new SessionCredentialError({ message: "Failed to encode claims", cause }),
228 ),
229 );
230 const signature = signPayload(encodedPayload, signingSecret);
231 const client = input?.client ?? createDefaultClientMetadata();
232 yield* authSessions.create({
233 sessionId,
234 subject: claims.sub,
235 role: claims.role,
236 method: claims.method,
237 client: {
238 label: client.label ?? null,
239 ipAddress: client.ipAddress ?? null,
240 userAgent: client.userAgent ?? null,
241 deviceType: client.deviceType,
242 os: client.os ?? null,
243 browser: client.browser ?? null,
244 },
245 issuedAt,
246 expiresAt,
247 });
248 yield* emitUpsert(
249 toAuthClientSession({
250 sessionId,
251 subject: claims.sub,
252 role: claims.role,
253 method: claims.method,
254 client,
255 issuedAt,
256 expiresAt,
257 lastConnectedAt: null,
258 connected: false,
259 }),
260 );
261
262 return {
263 sessionId,
264 token: `${encodedPayload}.${signature}`,
265 method: claims.method,
266 client,
267 expiresAt: expiresAt,
268 role: claims.role,
269 } satisfies IssuedSession;
270 }).pipe(Effect.mapError(toSessionCredentialError("Failed to issue session credential.")));
271
272 const verify: SessionCredentialServiceShape["verify"] = (token) =>
273 Effect.gen(function* () {
274 const [encodedPayload, signature] = token.split(".");
275 if (!encodedPayload || !signature) {
276 return yield* new SessionCredentialError({
277 message: "Malformed session token.",
278 });
279 }
280
281 const expectedSignature = signPayload(encodedPayload, signingSecret);
282 if (!timingSafeEqualBase64Url(signature, expectedSignature)) {
283 return yield* new SessionCredentialError({
284 message: "Invalid session token signature.",
285 });
286 }
287
288 const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
289 Effect.mapError(
290 (cause) =>
291 new SessionCredentialError({
292 message: "Invalid session token payload.",
293 cause,
294 }),
295 ),
296 );
297
298 const now = yield* Clock.currentTimeMillis;
299 if (claims.exp <= now) {
300 return yield* new SessionCredentialError({
301 message: "Session token expired.",
302 });
303 }
304
305 const row = yield* authSessions.getById({ sessionId: claims.sid });
306 if (Option.isNone(row)) {
307 return yield* new SessionCredentialError({
308 message: "Unknown session token.",
309 });
310 }
311 if (row.value.revokedAt !== null) {
312 return yield* new SessionCredentialError({
313 message: "Session token revoked.",
314 });
315 }
316
317 const expiresAt = DateTime.make(claims.exp);
318 if (Option.isNone(expiresAt)) {
319 return yield* new SessionCredentialError({
320 message: "Invalid `exp` claim",
321 });
322 }
323
324 return {
325 sessionId: claims.sid,
326 token,
327 method: claims.method,
328 client: toClientMetadata(row.value.client),
329 expiresAt: expiresAt.value,
330 subject: claims.sub,
331 role: claims.role,
332 } satisfies VerifiedSession;
333 }).pipe(
334 Effect.mapError((cause) =>
335 cause instanceof SessionCredentialError
336 ? cause
337 : new SessionCredentialError({
338 message: "Failed to verify session credential.",
339 cause,
340 }),
341 ),
342 );
343
344 const encodeWsClaims = Schema.encodeEffect(Schema.fromJsonString(WebSocketClaims));
345 const issueWebSocketToken: SessionCredentialServiceShape["issueWebSocketToken"] = (
346 sessionId,
347 input,
348 ) =>
349 Effect.gen(function* () {
350 const issuedAt = yield* DateTime.now;
351 const expiresAt = DateTime.add(issuedAt, {
352 milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL),
353 });
354 const claims: WebSocketClaims = {
355 v: 1,
356 kind: "websocket",
357 sid: sessionId,
358 iat: issuedAt.epochMilliseconds,
359 exp: expiresAt.epochMilliseconds,
360 };
361 const encodedPayload = yield* encodeWsClaims(claims).pipe(
362 Effect.map(base64UrlEncode),
363 Effect.mapError(
364 (cause) => new SessionCredentialError({ message: "Failed to encode claims", cause }),
365 ),
366 );
367 const signature = signPayload(encodedPayload, signingSecret);
368 return {
369 token: `${encodedPayload}.${signature}`,
370 expiresAt,
371 };
372 }).pipe(Effect.mapError(toSessionCredentialError("Failed to issue websocket token.")));
373
374 const verifyWebSocketToken: SessionCredentialServiceShape["verifyWebSocketToken"] = (token) =>
375 Effect.gen(function* () {
376 const [encodedPayload, signature] = token.split(".");
377 if (!encodedPayload || !signature) {
378 return yield* new SessionCredentialError({
379 message: "Malformed websocket token.",
380 });
381 }
382
383 const expectedSignature = signPayload(encodedPayload, signingSecret);
384 if (!timingSafeEqualBase64Url(signature, expectedSignature)) {
385 return yield* new SessionCredentialError({
386 message: "Invalid websocket token signature.",
387 });
388 }
389
390 const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
391 Effect.mapError(
392 (cause) =>
393 new SessionCredentialError({
394 message: "Invalid websocket token payload.",
395 cause,
396 }),
397 ),
398 );
399
400 const now = yield* Clock.currentTimeMillis;
401 if (claims.exp <= now) {
402 return yield* new SessionCredentialError({
403 message: "Websocket token expired.",
404 });
405 }
406
407 const row = yield* authSessions.getById({ sessionId: claims.sid });
408 if (Option.isNone(row)) {
409 return yield* new SessionCredentialError({
410 message: "Unknown websocket session.",
411 });
412 }
413 if (row.value.expiresAt.epochMilliseconds <= now) {
414 return yield* new SessionCredentialError({
415 message: "Websocket session expired.",
416 });
417 }
418 if (row.value.revokedAt !== null) {
419 return yield* new SessionCredentialError({
420 message: "Websocket session revoked.",
421 });
422 }
423
424 return {
425 sessionId: row.value.sessionId,
426 token,
427 method: row.value.method,
428 client: toClientMetadata(row.value.client),
429 expiresAt: row.value.expiresAt,
430 subject: row.value.subject,
431 role: row.value.role,
432 } satisfies VerifiedSession;
433 }).pipe(
434 Effect.mapError((cause) =>
435 cause instanceof SessionCredentialError
436 ? cause
437 : new SessionCredentialError({
438 message: "Failed to verify websocket token.",
439 cause,
440 }),
441 ),
442 );
443
444 const listActive: SessionCredentialServiceShape["listActive"] = () =>
445 Effect.gen(function* () {
446 const now = yield* DateTime.now;
447 const connectedSessions = yield* Ref.get(connectedSessionsRef);
448 const rows = yield* authSessions.listActive({ now });
449
450 return rows.map((row) =>
451 toAuthClientSession({
452 sessionId: row.sessionId,
453 subject: row.subject,
454 role: row.role,
455 method: row.method,
456 client: toClientMetadata(row.client),
457 issuedAt: row.issuedAt,
458 expiresAt: row.expiresAt,
459 lastConnectedAt: row.lastConnectedAt,
460 connected: connectedSessions.has(row.sessionId),
461 }),
462 );
463 }).pipe(Effect.mapError(toSessionCredentialError("Failed to list active sessions.")));
464
465 const revoke: SessionCredentialServiceShape["revoke"] = (sessionId) =>
466 Effect.gen(function* () {
467 const revokedAt = yield* DateTime.now;
468 const revoked = yield* authSessions.revoke({
469 sessionId,
470 revokedAt,
471 });
472 if (revoked) {
473 yield* Ref.update(connectedSessionsRef, (current) => {
474 const next = new Map(current);
475 next.delete(sessionId);
476 return next;
477 });
478 yield* emitRemoved(sessionId);
479 }
480 return revoked;
481 }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke session.")));
482
483 const revokeAllExcept: SessionCredentialServiceShape["revokeAllExcept"] = (sessionId) =>
484 Effect.gen(function* () {
485 const revokedAt = yield* DateTime.now;
486 const revokedSessionIds = yield* authSessions.revokeAllExcept({
487 currentSessionId: sessionId,
488 revokedAt,
489 });
490 if (revokedSessionIds.length > 0) {
491 yield* Ref.update(connectedSessionsRef, (current) => {
492 const next = new Map(current);
493 for (const revokedSessionId of revokedSessionIds) {
494 next.delete(revokedSessionId);
495 }
496 return next;
497 });
498 yield* Effect.forEach(
499 revokedSessionIds,
500 (revokedSessionId) => emitRemoved(revokedSessionId),
501 {
502 concurrency: "unbounded",
503 discard: true,
504 },
505 );
506 }
507 return revokedSessionIds.length;
508 }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke other sessions.")));
509
510 return {
511 cookieName,
512 issue,
513 verify,
514 issueWebSocketToken,
515 verifyWebSocketToken,
516 listActive,
517 get streamChanges() {
518 return Stream.fromPubSub(changesPubSub);
519 },
520 revoke,
521 revokeAllExcept,
522 markConnected,
523 markDisconnected,
524 } satisfies SessionCredentialServiceShape;
525});
526
527export const SessionCredentialServiceLive = Layer.effect(
528 SessionCredentialService,
529 makeSessionCredentialService,
530).pipe(Layer.provideMerge(AuthSessionRepositoryLive));
apps/server/src/auth/PairingGrantStore.test.ts created+167
...@@ -0,0 +1,167 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as TestClock from "effect/testing/TestClock";
7
8import type { ServerConfigShape } from "../config.ts";
9import { ServerConfig } from "../config.ts";
10import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
11import * as PairingGrantStore from "./PairingGrantStore.ts";
12
13const makeServerConfigLayer = (
14 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
15) =>
16 Layer.effect(
17 ServerConfig,
18 Effect.gen(function* () {
19 const config = yield* ServerConfig;
20 return {
21 ...config,
22 ...overrides,
23 } satisfies ServerConfigShape;
24 }),
25 ).pipe(
26 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-bootstrap-test-" })),
27 );
28
29const makePairingGrantStoreLayer = (
30 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
31) =>
32 PairingGrantStore.layer.pipe(
33 Layer.provide(SqlitePersistenceMemory),
34 Layer.provide(makeServerConfigLayer(overrides)),
35 );
36
37it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
38 it.effect("issues pairing tokens in a short manual-entry format", () =>
39 Effect.gen(function* () {
40 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
41 const issued = yield* bootstrapCredentials.issueOneTimeToken();
42
43 expect(issued.credential).toMatch(/^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{12}$/);
44 }).pipe(Effect.provide(makePairingGrantStoreLayer())),
45 );
46
47 it.effect("issues one-time bootstrap tokens that can only be consumed once", () =>
48 Effect.gen(function* () {
49 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
50 const issued = yield* bootstrapCredentials.issueOneTimeToken({ label: "Julius iPhone" });
51 const first = yield* bootstrapCredentials.consume(issued.credential);
52 const second = yield* Effect.flip(bootstrapCredentials.consume(issued.credential));
53
54 expect(first.method).toBe("one-time-token");
55 expect(first.scopes).toEqual([
56 "orchestration:read",
57 "orchestration:operate",
58 "terminal:operate",
59 "review:write",
60 "relay:read",
61 ]);
62 expect(first.subject).toBe("one-time-token");
63 expect(first.label).toBe("Julius iPhone");
64 expect(issued.label).toBe("Julius iPhone");
65 expect(second._tag).toBe("BootstrapCredentialInvalidError");
66 expect(second.message).toContain("Unknown bootstrap credential");
67 }).pipe(Effect.provide(makePairingGrantStoreLayer())),
68 );
69
70 it.effect("atomically consumes a one-time token when multiple requests race", () =>
71 Effect.gen(function* () {
72 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
73 const token = yield* bootstrapCredentials.issueOneTimeToken();
74 const results = yield* Effect.all(
75 Array.from({ length: 8 }, () =>
76 Effect.result(bootstrapCredentials.consume(token.credential)),
77 ),
78 {
79 concurrency: "unbounded",
80 },
81 );
82
83 const successes = results.filter((result) => result._tag === "Success");
84 const failures = results.filter((result) => result._tag === "Failure");
85
86 expect(successes).toHaveLength(1);
87 expect(failures).toHaveLength(7);
88 for (const failure of failures) {
89 expect(failure.failure._tag).toBe("BootstrapCredentialInvalidError");
90 expect(failure.failure.message).toContain("Unknown bootstrap credential");
91 }
92 }).pipe(Effect.provide(makePairingGrantStoreLayer())),
93 );
94
95 it.effect("seeds the desktop bootstrap credential as a one-time grant", () =>
96 Effect.gen(function* () {
97 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
98 const first = yield* bootstrapCredentials.consume("desktop-bootstrap-token");
99 const second = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token"));
100
101 expect(first.method).toBe("desktop-bootstrap");
102 expect(first.scopes).toEqual([
103 "orchestration:read",
104 "orchestration:operate",
105 "terminal:operate",
106 "review:write",
107 "relay:read",
108 "access:read",
109 "access:write",
110 "relay:write",
111 ]);
112 expect(first.subject).toBe("desktop-bootstrap");
113 expect(second._tag).toBe("BootstrapCredentialInvalidError");
114 }).pipe(
115 Effect.provide(
116 makePairingGrantStoreLayer({
117 desktopBootstrapToken: "desktop-bootstrap-token",
118 }),
119 ),
120 ),
121 );
122
123 it.effect("reports seeded desktop bootstrap credentials as expired after their ttl", () =>
124 Effect.gen(function* () {
125 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
126
127 yield* TestClock.adjust(Duration.minutes(6));
128 const expired = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token"));
129
130 expect(expired._tag).toBe("BootstrapCredentialInvalidError");
131 expect(expired.message).toContain("Bootstrap credential expired");
132 }).pipe(
133 Effect.provide(
134 Layer.merge(
135 makePairingGrantStoreLayer({
136 desktopBootstrapToken: "desktop-bootstrap-token",
137 }),
138 TestClock.layer(),
139 ),
140 ),
141 ),
142 );
143
144 it.effect("lists and revokes active pairing links", () =>
145 Effect.gen(function* () {
146 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
147 const first = yield* bootstrapCredentials.issueOneTimeToken();
148 const second = yield* bootstrapCredentials.issueOneTimeToken({
149 scopes: ["orchestration:read", "access:write"],
150 });
151
152 const activeBeforeRevoke = yield* bootstrapCredentials.listActive();
153 expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(first.id);
154 expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(second.id);
155
156 const revoked = yield* bootstrapCredentials.revoke(first.id);
157 const activeAfterRevoke = yield* bootstrapCredentials.listActive();
158 const revokedConsume = yield* Effect.flip(bootstrapCredentials.consume(first.credential));
159
160 expect(revoked).toBe(true);
161 expect(activeAfterRevoke.map((entry) => entry.id)).not.toContain(first.id);
162 expect(activeAfterRevoke.map((entry) => entry.id)).toContain(second.id);
163 expect(revokedConsume.message).toContain("no longer available");
164 expect(revokedConsume._tag).toBe("BootstrapCredentialInvalidError");
165 }).pipe(Effect.provide(makePairingGrantStoreLayer())),
166 );
167});
apps/server/src/auth/PairingGrantStore.ts created+386
...@@ -0,0 +1,386 @@
1import {
2 AuthAdministrativeScopes,
3 AuthStandardClientScopes,
4 type AuthEnvironmentScope,
5 type AuthPairingLink,
6 type ServerAuthBootstrapMethod,
7} from "@t3tools/contracts";
8import * as Context from "effect/Context";
9import * as Crypto from "effect/Crypto";
10import * as Data from "effect/Data";
11import * as DateTime from "effect/DateTime";
12import * as Duration from "effect/Duration";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import * as PubSub from "effect/PubSub";
16import * as Ref from "effect/Ref";
17import * as Stream from "effect/Stream";
18import * as Option from "effect/Option";
19
20import { ServerConfig } from "../config.ts";
21import { AuthPairingLinkRepositoryLive } from "../persistence/Layers/AuthPairingLinks.ts";
22import { AuthPairingLinkRepository } from "../persistence/Services/AuthPairingLinks.ts";
23
24export interface BootstrapGrant {
25 readonly method: ServerAuthBootstrapMethod;
26 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
27 readonly subject: string;
28 readonly label?: string;
29 readonly expiresAt: DateTime.DateTime;
30}
31
32export class BootstrapCredentialInvalidError extends Data.TaggedError(
33 "BootstrapCredentialInvalidError",
34)<{
35 readonly message: string;
36}> {}
37
38export class BootstrapCredentialInternalError extends Data.TaggedError(
39 "BootstrapCredentialInternalError",
40)<{
41 readonly message: string;
42 readonly cause?: unknown;
43}> {}
44
45export type BootstrapCredentialError =
46 | BootstrapCredentialInvalidError
47 | BootstrapCredentialInternalError;
48
49export interface IssuedBootstrapCredential {
50 readonly id: string;
51 readonly credential: string;
52 readonly label?: string;
53 readonly expiresAt: DateTime.Utc;
54}
55
56export type BootstrapCredentialChange =
57 | {
58 readonly type: "pairingLinkUpserted";
59 readonly pairingLink: AuthPairingLink;
60 }
61 | {
62 readonly type: "pairingLinkRemoved";
63 readonly id: string;
64 };
65
66export interface PairingGrantStoreShape {
67 readonly issueOneTimeToken: (input?: {
68 readonly ttl?: Duration.Duration;
69 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
70 readonly subject?: string;
71 readonly label?: string;
72 }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialInternalError>;
73 readonly listActive: () => Effect.Effect<
74 ReadonlyArray<AuthPairingLink>,
75 BootstrapCredentialInternalError
76 >;
77 readonly streamChanges: Stream.Stream<BootstrapCredentialChange>;
78 readonly revoke: (id: string) => Effect.Effect<boolean, BootstrapCredentialInternalError>;
79 readonly consume: (credential: string) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>;
80}
81
82export class PairingGrantStore extends Context.Service<PairingGrantStore, PairingGrantStoreShape>()(
83 "t3/auth/PairingGrantStore",
84) {}
85
86interface StoredBootstrapGrant extends BootstrapGrant {
87 readonly remainingUses: number | "unbounded";
88}
89
90type ConsumeResult =
91 | {
92 readonly _tag: "error";
93 readonly reason: "not-found" | "expired";
94 readonly error: BootstrapCredentialError;
95 }
96 | {
97 readonly _tag: "success";
98 readonly grant: BootstrapGrant;
99 };
100
101const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5);
102const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
103const PAIRING_TOKEN_LENGTH = 12;
104const PAIRING_TOKEN_REJECTION_LIMIT =
105 Math.floor(256 / PAIRING_TOKEN_ALPHABET.length) * PAIRING_TOKEN_ALPHABET.length;
106
107const invalidBootstrapCredentialError = (message: string) =>
108 new BootstrapCredentialInvalidError({
109 message,
110 });
111
112const internalBootstrapCredentialError = (message: string, cause: unknown) =>
113 new BootstrapCredentialInternalError({
114 message,
115 cause,
116 });
117
118export const make = Effect.fn("makePairingGrantStore")(function* () {
119 const crypto = yield* Crypto.Crypto;
120 const config = yield* ServerConfig;
121 const pairingLinks = yield* AuthPairingLinkRepository;
122 const seededGrantsRef = yield* Ref.make(new Map<string, StoredBootstrapGrant>());
123 const changesPubSub = yield* PubSub.unbounded<BootstrapCredentialChange>();
124 const generatePairingToken = Effect.gen(function* () {
125 let credential = "";
126 while (credential.length < PAIRING_TOKEN_LENGTH) {
127 const bytes = yield* crypto.randomBytes(PAIRING_TOKEN_LENGTH);
128 for (const byte of bytes) {
129 if (byte >= PAIRING_TOKEN_REJECTION_LIMIT) {
130 continue;
131 }
132 credential += PAIRING_TOKEN_ALPHABET[byte % PAIRING_TOKEN_ALPHABET.length]!;
133 if (credential.length === PAIRING_TOKEN_LENGTH) {
134 return credential;
135 }
136 }
137 }
138 return credential;
139 });
140
141 const seedGrant = (credential: string, grant: StoredBootstrapGrant) =>
142 Ref.update(seededGrantsRef, (current) => {
143 const next = new Map(current);
144 next.set(credential, grant);
145 return next;
146 });
147
148 const emitUpsert = (pairingLink: AuthPairingLink) =>
149 PubSub.publish(changesPubSub, {
150 type: "pairingLinkUpserted",
151 pairingLink,
152 }).pipe(Effect.asVoid);
153
154 const emitRemoved = (id: string) =>
155 PubSub.publish(changesPubSub, {
156 type: "pairingLinkRemoved",
157 id,
158 }).pipe(Effect.asVoid);
159
160 if (config.desktopBootstrapToken) {
161 const now = yield* DateTime.now;
162 yield* seedGrant(config.desktopBootstrapToken, {
163 method: "desktop-bootstrap",
164 scopes: AuthAdministrativeScopes,
165 subject: "desktop-bootstrap",
166 expiresAt: DateTime.add(now, {
167 milliseconds: Duration.toMillis(DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES),
168 }),
169 remainingUses: 1,
170 });
171 }
172
173 const toBootstrapCredentialError = (message: string) => (cause: unknown) =>
174 internalBootstrapCredentialError(message, cause);
175
176 const listActive: PairingGrantStoreShape["listActive"] = Effect.fn(
177 "PairingGrantStore.listActive",
178 )(
179 function* () {
180 const now = yield* DateTime.now;
181 const rows = yield* pairingLinks.listActive({ now });
182
183 return rows.map((row) =>
184 row.label
185 ? ({
186 id: row.id,
187 credential: row.credential,
188 scopes: row.scopes,
189 subject: row.subject,
190 label: row.label,
191 createdAt: row.createdAt,
192 expiresAt: row.expiresAt,
193 } satisfies AuthPairingLink)
194 : ({
195 id: row.id,
196 credential: row.credential,
197 scopes: row.scopes,
198 subject: row.subject,
199 createdAt: row.createdAt,
200 expiresAt: row.expiresAt,
201 } satisfies AuthPairingLink),
202 );
203 },
204 Effect.mapError(toBootstrapCredentialError("Failed to load active pairing links.")),
205 );
206
207 const revoke: PairingGrantStoreShape["revoke"] = Effect.fn("PairingGrantStore.revoke")(
208 function* (id) {
209 const revokedAt = yield* DateTime.now;
210 const revoked = yield* pairingLinks.revoke({
211 id,
212 revokedAt,
213 });
214 if (revoked) {
215 yield* emitRemoved(id);
216 }
217 return revoked;
218 },
219 Effect.mapError(toBootstrapCredentialError("Failed to revoke pairing link.")),
220 );
221
222 const issueOneTimeToken: PairingGrantStoreShape["issueOneTimeToken"] = Effect.fn(
223 "PairingGrantStore.issueOneTimeToken",
224 )(
225 function* (input) {
226 const id = yield* crypto.randomUUIDv4;
227 const credential = yield* generatePairingToken;
228 const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES;
229 const now = yield* DateTime.now;
230 const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) });
231 const issued: IssuedBootstrapCredential = {
232 id,
233 credential,
234 ...(input?.label ? { label: input.label } : {}),
235 expiresAt,
236 };
237 yield* pairingLinks.create({
238 id,
239 credential,
240 method: "one-time-token",
241 scopes: input?.scopes ?? AuthStandardClientScopes,
242 subject: input?.subject ?? "one-time-token",
243 label: input?.label ?? null,
244 createdAt: now,
245 expiresAt: expiresAt,
246 });
247 yield* emitUpsert({
248 id,
249 credential,
250 scopes: input?.scopes ?? AuthStandardClientScopes,
251 subject: input?.subject ?? "one-time-token",
252 ...(input?.label ? { label: input.label } : {}),
253 createdAt: now,
254 expiresAt,
255 });
256 return issued;
257 },
258 Effect.mapError(toBootstrapCredentialError("Failed to issue pairing credential.")),
259 );
260
261 const consume: PairingGrantStoreShape["consume"] = Effect.fn("PairingGrantStore.consume")(
262 function* (credential) {
263 const now = yield* DateTime.now;
264 const seededResult: ConsumeResult = yield* Ref.modify(
265 seededGrantsRef,
266 (current): readonly [ConsumeResult, Map<string, StoredBootstrapGrant>] => {
267 const grant = current.get(credential);
268 if (!grant) {
269 return [
270 {
271 _tag: "error",
272 reason: "not-found",
273 error: invalidBootstrapCredentialError("Unknown bootstrap credential."),
274 },
275 current,
276 ];
277 }
278
279 const next = new Map(current);
280 if (DateTime.isGreaterThanOrEqualTo(now, grant.expiresAt)) {
281 next.delete(credential);
282 return [
283 {
284 _tag: "error",
285 reason: "expired",
286 error: invalidBootstrapCredentialError("Bootstrap credential expired."),
287 },
288 next,
289 ];
290 }
291
292 const remainingUses = grant.remainingUses;
293 if (typeof remainingUses === "number") {
294 if (remainingUses <= 1) {
295 next.delete(credential);
296 } else {
297 next.set(credential, {
298 ...grant,
299 remainingUses: remainingUses - 1,
300 });
301 }
302 }
303
304 return [
305 {
306 _tag: "success",
307 grant: {
308 method: grant.method,
309 scopes: grant.scopes,
310 subject: grant.subject,
311 ...(grant.label ? { label: grant.label } : {}),
312 expiresAt: grant.expiresAt,
313 } satisfies BootstrapGrant,
314 },
315 next,
316 ];
317 },
318 );
319
320 if (seededResult._tag === "success") {
321 return seededResult.grant;
322 }
323 if (seededResult.reason !== "not-found") {
324 return yield* seededResult.error;
325 }
326
327 const consumed = yield* pairingLinks.consumeAvailable({
328 credential,
329 consumedAt: now,
330 now,
331 });
332
333 if (Option.isSome(consumed)) {
334 yield* emitRemoved(consumed.value.id);
335 return {
336 method: consumed.value.method,
337 scopes: consumed.value.scopes,
338 subject: consumed.value.subject,
339 ...(consumed.value.label ? { label: consumed.value.label } : {}),
340 expiresAt: consumed.value.expiresAt,
341 } satisfies BootstrapGrant;
342 }
343
344 const matching = yield* pairingLinks.getByCredential({ credential });
345 if (Option.isNone(matching)) {
346 return yield* invalidBootstrapCredentialError("Unknown bootstrap credential.");
347 }
348
349 if (matching.value.revokedAt !== null) {
350 return yield* invalidBootstrapCredentialError(
351 "Bootstrap credential is no longer available.",
352 );
353 }
354
355 if (matching.value.consumedAt !== null) {
356 return yield* invalidBootstrapCredentialError("Unknown bootstrap credential.");
357 }
358
359 if (DateTime.isGreaterThanOrEqualTo(now, matching.value.expiresAt)) {
360 return yield* invalidBootstrapCredentialError("Bootstrap credential expired.");
361 }
362
363 return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available.");
364 },
365 Effect.mapError((cause) =>
366 cause._tag === "BootstrapCredentialInvalidError" ||
367 cause._tag === "BootstrapCredentialInternalError"
368 ? cause
369 : internalBootstrapCredentialError("Failed to consume bootstrap credential.", cause),
370 ),
371 );
372
373 return {
374 issueOneTimeToken,
375 listActive,
376 get streamChanges() {
377 return Stream.fromPubSub(changesPubSub);
378 },
379 revoke,
380 consume,
381 } satisfies PairingGrantStoreShape;
382});
383
384export const layer = Layer.effect(PairingGrantStore, make()).pipe(
385 Layer.provideMerge(AuthPairingLinkRepositoryLive),
386);
apps/server/src/auth/ServerSecretStore.test.ts created+267
...@@ -0,0 +1,267 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as Cause from "effect/Cause";
4import * as Deferred from "effect/Deferred";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Ref from "effect/Ref";
9import * as PlatformError from "effect/PlatformError";
10
11import { ServerConfig } from "../config.ts";
12import * as ServerSecretStore from "./ServerSecretStore.ts";
13
14const makeServerConfigLayer = () =>
15 ServerConfig.layerTest(process.cwd(), { prefix: "t3-secret-store-test-" });
16
17const makeServerSecretStoreLayer = () =>
18 Layer.provide(ServerSecretStore.layer, makeServerConfigLayer());
19
20const PermissionDeniedFileSystemLayer = Layer.effect(
21 FileSystem.FileSystem,
22 Effect.gen(function* () {
23 const fileSystem = yield* FileSystem.FileSystem;
24
25 return {
26 ...fileSystem,
27 readFile: (path) =>
28 Effect.fail(
29 PlatformError.systemError({
30 _tag: "PermissionDenied",
31 module: "FileSystem",
32 method: "readFile",
33 pathOrDescriptor: path,
34 description: "Permission denied while reading secret file.",
35 }),
36 ),
37 } satisfies FileSystem.FileSystem;
38 }),
39).pipe(Layer.provide(NodeServices.layer));
40
41const makePermissionDeniedSecretStoreLayer = () =>
42 ServerSecretStore.layer.pipe(
43 Layer.provide(makeServerConfigLayer()),
44 Layer.provideMerge(PermissionDeniedFileSystemLayer),
45 );
46
47const RenameFailureFileSystemLayer = Layer.effect(
48 FileSystem.FileSystem,
49 Effect.gen(function* () {
50 const fileSystem = yield* FileSystem.FileSystem;
51
52 return {
53 ...fileSystem,
54 rename: (from, to) =>
55 Effect.fail(
56 PlatformError.systemError({
57 _tag: "PermissionDenied",
58 module: "FileSystem",
59 method: "rename",
60 pathOrDescriptor: `${String(from)} -> ${String(to)}`,
61 description: "Permission denied while persisting secret file.",
62 }),
63 ),
64 } satisfies FileSystem.FileSystem;
65 }),
66).pipe(Layer.provide(NodeServices.layer));
67
68const makeRenameFailureSecretStoreLayer = () =>
69 ServerSecretStore.layer.pipe(
70 Layer.provide(makeServerConfigLayer()),
71 Layer.provideMerge(RenameFailureFileSystemLayer),
72 );
73
74const RemoveFailureFileSystemLayer = Layer.effect(
75 FileSystem.FileSystem,
76 Effect.gen(function* () {
77 const fileSystem = yield* FileSystem.FileSystem;
78
79 return {
80 ...fileSystem,
81 remove: (path, options) =>
82 Effect.fail(
83 PlatformError.systemError({
84 _tag: "PermissionDenied",
85 module: "FileSystem",
86 method: "remove",
87 pathOrDescriptor: String(path),
88 description: `Permission denied while removing secret file.${options ? " options-set" : ""}`,
89 }),
90 ),
91 } satisfies FileSystem.FileSystem;
92 }),
93).pipe(Layer.provide(NodeServices.layer));
94
95const makeRemoveFailureSecretStoreLayer = () =>
96 ServerSecretStore.layer.pipe(
97 Layer.provide(makeServerConfigLayer()),
98 Layer.provideMerge(RemoveFailureFileSystemLayer),
99 );
100
101const ConcurrentReadMissFileSystemLayer = Layer.effect(
102 FileSystem.FileSystem,
103 Effect.gen(function* () {
104 const fileSystem = yield* FileSystem.FileSystem;
105 const readCountRef = yield* Ref.make(0);
106 const readBarrier = yield* Deferred.make<void>();
107
108 return {
109 ...fileSystem,
110 readFile: (path) =>
111 String(path).endsWith("/session-signing-key.bin")
112 ? Ref.updateAndGet(readCountRef, (count) => count + 1).pipe(
113 Effect.flatMap((count) => {
114 if (count > 2) {
115 return fileSystem.readFile(path);
116 }
117 return Effect.gen(function* () {
118 if (count === 2) {
119 yield* Deferred.succeed(readBarrier, void 0);
120 }
121 yield* Deferred.await(readBarrier);
122 return yield* Effect.failCause(
123 Cause.fail(
124 PlatformError.systemError({
125 _tag: "NotFound",
126 module: "FileSystem",
127 method: "readFile",
128 pathOrDescriptor: String(path),
129 description: "Secret file does not exist yet.",
130 }),
131 ),
132 );
133 });
134 }),
135 )
136 : fileSystem.readFile(path),
137 } satisfies FileSystem.FileSystem;
138 }),
139).pipe(Layer.provide(NodeServices.layer));
140
141const makeConcurrentCreateSecretStoreLayer = () =>
142 ServerSecretStore.layer.pipe(
143 Layer.provide(makeServerConfigLayer()),
144 Layer.provideMerge(ConcurrentReadMissFileSystemLayer),
145 );
146
147it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => {
148 it.effect("returns null when a secret file does not exist", () =>
149 Effect.gen(function* () {
150 const secretStore = yield* ServerSecretStore.ServerSecretStore;
151
152 const secret = yield* secretStore.get("missing-secret");
153
154 expect(secret).toBeNull();
155 }).pipe(Effect.provide(makeServerSecretStoreLayer())),
156 );
157
158 it.effect("reuses an existing secret instead of regenerating it", () =>
159 Effect.gen(function* () {
160 const secretStore = yield* ServerSecretStore.ServerSecretStore;
161
162 const first = yield* secretStore.getOrCreateRandom("session-signing-key", 32);
163 const second = yield* secretStore.getOrCreateRandom("session-signing-key", 32);
164
165 expect(Array.from(second)).toEqual(Array.from(first));
166 }).pipe(Effect.provide(makeServerSecretStoreLayer())),
167 );
168
169 it.effect("returns the persisted secret when concurrent creators race", () =>
170 Effect.gen(function* () {
171 const secretStore = yield* ServerSecretStore.ServerSecretStore;
172
173 const [first, second] = yield* Effect.all(
174 [
175 secretStore.getOrCreateRandom("session-signing-key", 32),
176 secretStore.getOrCreateRandom("session-signing-key", 32),
177 ],
178 { concurrency: "unbounded" },
179 );
180 const persisted = yield* secretStore.get("session-signing-key");
181
182 expect(persisted).not.toBeNull();
183 expect(Array.from(first)).toEqual(Array.from(persisted ?? new Uint8Array()));
184 expect(Array.from(second)).toEqual(Array.from(persisted ?? new Uint8Array()));
185 }).pipe(Effect.provide(makeConcurrentCreateSecretStoreLayer())),
186 );
187
188 it.effect("uses restrictive permissions for the secret directory and files", () =>
189 Effect.gen(function* () {
190 const chmodCalls: Array<{ readonly path: string; readonly mode: number }> = [];
191 const recordingFileSystemLayer = Layer.effect(
192 FileSystem.FileSystem,
193 Effect.gen(function* () {
194 const fileSystem = yield* FileSystem.FileSystem;
195
196 return {
197 ...fileSystem,
198 makeDirectory: () => Effect.void,
199 writeFile: () => Effect.void,
200 rename: () => Effect.void,
201 chmod: (path, mode) =>
202 Effect.sync(() => {
203 chmodCalls.push({ path: String(path), mode });
204 }),
205 } satisfies FileSystem.FileSystem;
206 }),
207 ).pipe(Layer.provide(NodeServices.layer));
208
209 const secretStore = yield* Effect.service(ServerSecretStore.ServerSecretStore).pipe(
210 Effect.provide(
211 ServerSecretStore.layer.pipe(
212 Layer.provide(makeServerConfigLayer()),
213 Layer.provideMerge(recordingFileSystemLayer),
214 ),
215 ),
216 );
217
218 yield* secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3]));
219
220 expect(chmodCalls.some((call) => call.mode === 0o700 && call.path.endsWith("/secrets"))).toBe(
221 true,
222 );
223 expect(chmodCalls.filter((call) => call.mode === 0o600).length).toBeGreaterThanOrEqual(2);
224 }).pipe(Effect.provide(NodeServices.layer)),
225 );
226
227 it.effect("propagates read failures other than missing-file errors", () =>
228 Effect.gen(function* () {
229 const secretStore = yield* ServerSecretStore.ServerSecretStore;
230
231 const error = yield* Effect.flip(secretStore.getOrCreateRandom("session-signing-key", 32));
232
233 expect(error).toBeInstanceOf(ServerSecretStore.SecretStoreError);
234 expect(error.message).toContain("Failed to read secret session-signing-key.");
235 expect(error.cause).toBeInstanceOf(PlatformError.PlatformError);
236 expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied");
237 }).pipe(Effect.provide(makePermissionDeniedSecretStoreLayer())),
238 );
239
240 it.effect("propagates write failures instead of treating them as success", () =>
241 Effect.gen(function* () {
242 const secretStore = yield* ServerSecretStore.ServerSecretStore;
243
244 const error = yield* Effect.flip(
245 secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])),
246 );
247
248 expect(error).toBeInstanceOf(ServerSecretStore.SecretStoreError);
249 expect(error.message).toContain("Failed to persist secret session-signing-key.");
250 expect(error.cause).toBeInstanceOf(PlatformError.PlatformError);
251 expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied");
252 }).pipe(Effect.provide(makeRenameFailureSecretStoreLayer())),
253 );
254
255 it.effect("propagates remove failures other than missing-file errors", () =>
256 Effect.gen(function* () {
257 const secretStore = yield* ServerSecretStore.ServerSecretStore;
258
259 const error = yield* Effect.flip(secretStore.remove("session-signing-key"));
260
261 expect(error).toBeInstanceOf(ServerSecretStore.SecretStoreError);
262 expect(error.message).toContain("Failed to remove secret session-signing-key.");
263 expect(error.cause).toBeInstanceOf(PlatformError.PlatformError);
264 expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied");
265 }).pipe(Effect.provide(makeRemoveFailureSecretStoreLayer())),
266 );
267});
apps/server/src/auth/ServerSecretStore.ts created+193
...@@ -0,0 +1,193 @@
1import * as Context from "effect/Context";
2import * as Crypto from "effect/Crypto";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8import * as Predicate from "effect/Predicate";
9import * as PlatformError from "effect/PlatformError";
10
11import { ServerConfig } from "../config.ts";
12
13export class SecretStoreError extends Data.TaggedError("SecretStoreError")<{
14 readonly message: string;
15 readonly cause?: unknown;
16}> {}
17
18export interface ServerSecretStoreShape {
19 readonly get: (name: string) => Effect.Effect<Uint8Array | null, SecretStoreError>;
20 readonly set: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>;
21 readonly getOrCreateRandom: (
22 name: string,
23 bytes: number,
24 ) => Effect.Effect<Uint8Array, SecretStoreError>;
25 readonly remove: (name: string) => Effect.Effect<void, SecretStoreError>;
26}
27
28export class ServerSecretStore extends Context.Service<ServerSecretStore, ServerSecretStoreShape>()(
29 "t3/auth/ServerSecretStore",
30) {}
31
32export const make = Effect.fn("makeServerSecretStore")(function* () {
33 const crypto = yield* Crypto.Crypto;
34 const fileSystem = yield* FileSystem.FileSystem;
35 const path = yield* Path.Path;
36 const serverConfig = yield* ServerConfig;
37
38 yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true });
39 yield* fileSystem.chmod(serverConfig.secretsDir, 0o700).pipe(
40 Effect.mapError(
41 (cause) =>
42 new SecretStoreError({
43 message: `Failed to secure secrets directory ${serverConfig.secretsDir}.`,
44 cause,
45 }),
46 ),
47 );
48
49 const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`);
50
51 const isPlatformError = (u: unknown): u is PlatformError.PlatformError =>
52 Predicate.isTagged(u, "PlatformError");
53
54 const get: ServerSecretStoreShape["get"] = (name) =>
55 fileSystem.readFile(resolveSecretPath(name)).pipe(
56 Effect.map((bytes) => Uint8Array.from(bytes)),
57 Effect.catch((cause) =>
58 cause.reason._tag === "NotFound"
59 ? Effect.succeed(null)
60 : Effect.fail(
61 new SecretStoreError({
62 message: `Failed to read secret ${name}.`,
63 cause,
64 }),
65 ),
66 ),
67 Effect.withSpan("ServerSecretStore.get"),
68 );
69
70 const set: ServerSecretStoreShape["set"] = (name, value) => {
71 const secretPath = resolveSecretPath(name);
72 return crypto.randomUUIDv4.pipe(
73 Effect.mapError(
74 (cause) =>
75 new SecretStoreError({
76 message: `Failed to create temporary path for secret ${name}.`,
77 cause,
78 }),
79 ),
80 Effect.flatMap((uuid) => {
81 const tempPath = `${secretPath}.${uuid}.tmp`;
82 return Effect.gen(function* () {
83 yield* fileSystem.writeFile(tempPath, value);
84 yield* fileSystem.chmod(tempPath, 0o600);
85 yield* fileSystem.rename(tempPath, secretPath);
86 yield* fileSystem.chmod(secretPath, 0o600);
87 }).pipe(
88 Effect.catch((cause) =>
89 fileSystem.remove(tempPath).pipe(
90 Effect.ignore,
91 Effect.flatMap(() =>
92 Effect.fail(
93 new SecretStoreError({
94 message: `Failed to persist secret ${name}.`,
95 cause,
96 }),
97 ),
98 ),
99 ),
100 ),
101 );
102 }),
103 Effect.withSpan("ServerSecretStore.set"),
104 );
105 };
106
107 const create: ServerSecretStoreShape["set"] = (name, value) => {
108 const secretPath = resolveSecretPath(name);
109 return Effect.scoped(
110 Effect.gen(function* () {
111 const file = yield* fileSystem.open(secretPath, {
112 flag: "wx",
113 mode: 0o600,
114 });
115 yield* file.writeAll(value);
116 yield* file.sync;
117 yield* fileSystem.chmod(secretPath, 0o600);
118 }),
119 ).pipe(
120 Effect.mapError(
121 (cause) =>
122 new SecretStoreError({
123 message: `Failed to persist secret ${name}.`,
124 cause,
125 }),
126 ),
127 );
128 };
129
130 const getOrCreateRandom: ServerSecretStoreShape["getOrCreateRandom"] = (name, bytes) =>
131 get(name).pipe(
132 Effect.flatMap((existing) => {
133 if (existing) {
134 return Effect.succeed(existing);
135 }
136
137 return crypto.randomBytes(bytes).pipe(
138 Effect.mapError(
139 (cause) =>
140 new SecretStoreError({
141 message: `Failed to generate random bytes for secret ${name}.`,
142 cause,
143 }),
144 ),
145 Effect.flatMap((generated) =>
146 create(name, generated).pipe(
147 Effect.as(Uint8Array.from(generated)),
148 Effect.catchTag("SecretStoreError", (error) =>
149 isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists"
150 ? get(name).pipe(
151 Effect.flatMap((created) =>
152 created !== null
153 ? Effect.succeed(created)
154 : Effect.fail(
155 new SecretStoreError({
156 message: `Failed to read secret ${name} after concurrent creation.`,
157 }),
158 ),
159 ),
160 )
161 : Effect.fail(error),
162 ),
163 ),
164 ),
165 );
166 }),
167 Effect.withSpan("ServerSecretStore.getOrCreateRandom"),
168 );
169
170 const remove: ServerSecretStoreShape["remove"] = (name) =>
171 fileSystem.remove(resolveSecretPath(name)).pipe(
172 Effect.catch((cause) =>
173 cause.reason._tag === "NotFound"
174 ? Effect.void
175 : Effect.fail(
176 new SecretStoreError({
177 message: `Failed to remove secret ${name}.`,
178 cause,
179 }),
180 ),
181 ),
182 Effect.withSpan("ServerSecretStore.remove"),
183 );
184
185 return {
186 get,
187 set,
188 getOrCreateRandom,
189 remove,
190 } satisfies ServerSecretStoreShape;
191});
192
193export const layer = Layer.effect(ServerSecretStore, make());
apps/server/src/auth/Services/AuthControlPlane.ts deleted-73
...@@ -1,73 +0,0 @@
1import type {
2 AuthClientMetadata,
3 AuthClientSession,
4 AuthPairingLink,
5 AuthSessionId,
6} from "@t3tools/contracts";
7import * as Data from "effect/Data";
8import * as DateTime from "effect/DateTime";
9import * as Duration from "effect/Duration";
10import * as Effect from "effect/Effect";
11import * as Context from "effect/Context";
12import type { SessionRole } from "./SessionCredentialService.ts";
13
14export const DEFAULT_SESSION_SUBJECT = "cli-issued-session";
15
16export interface IssuedPairingLink {
17 readonly id: string;
18 readonly credential: string;
19 readonly role: SessionRole;
20 readonly subject: string;
21 readonly label?: string;
22 readonly createdAt: DateTime.Utc;
23 readonly expiresAt: DateTime.Utc;
24}
25
26export interface IssuedBearerSession {
27 readonly sessionId: AuthSessionId;
28 readonly token: string;
29 readonly method: "bearer-session-token";
30 readonly role: SessionRole;
31 readonly subject: string;
32 readonly client: AuthClientMetadata;
33 readonly expiresAt: DateTime.Utc;
34}
35
36export class AuthControlPlaneError extends Data.TaggedError("AuthControlPlaneError")<{
37 readonly message: string;
38 readonly cause?: unknown;
39}> {}
40
41export interface AuthControlPlaneShape {
42 readonly createPairingLink: (input?: {
43 readonly ttl?: Duration.Duration;
44 readonly label?: string;
45 readonly role?: SessionRole;
46 readonly subject?: string;
47 }) => Effect.Effect<IssuedPairingLink, AuthControlPlaneError>;
48 readonly listPairingLinks: (input?: {
49 readonly role?: SessionRole;
50 readonly excludeSubjects?: ReadonlyArray<string>;
51 }) => Effect.Effect<ReadonlyArray<AuthPairingLink>, AuthControlPlaneError>;
52 readonly revokePairingLink: (id: string) => Effect.Effect<boolean, AuthControlPlaneError>;
53 readonly issueSession: (input?: {
54 readonly ttl?: Duration.Duration;
55 readonly subject?: string;
56 readonly role?: SessionRole;
57 readonly label?: string;
58 }) => Effect.Effect<IssuedBearerSession, AuthControlPlaneError>;
59 readonly listSessions: () => Effect.Effect<
60 ReadonlyArray<AuthClientSession>,
61 AuthControlPlaneError
62 >;
63 readonly revokeSession: (
64 sessionId: AuthSessionId,
65 ) => Effect.Effect<boolean, AuthControlPlaneError>;
66 readonly revokeOtherSessionsExcept: (
67 sessionId: AuthSessionId,
68 ) => Effect.Effect<number, AuthControlPlaneError>;
69}
70
71export class AuthControlPlane extends Context.Service<AuthControlPlane, AuthControlPlaneShape>()(
72 "t3/auth/Services/AuthControlPlane",
73) {}
apps/server/src/auth/Services/BootstrapCredentialService.ts deleted-61
...@@ -1,61 +0,0 @@
1import type { AuthPairingLink, ServerAuthBootstrapMethod } from "@t3tools/contracts";
2import * as Data from "effect/Data";
3import * as DateTime from "effect/DateTime";
4import * as Duration from "effect/Duration";
5import * as Context from "effect/Context";
6import type * as Effect from "effect/Effect";
7import type * as Stream from "effect/Stream";
8
9export type BootstrapCredentialRole = "owner" | "client";
10
11export interface BootstrapGrant {
12 readonly method: ServerAuthBootstrapMethod;
13 readonly role: BootstrapCredentialRole;
14 readonly subject: string;
15 readonly label?: string;
16 readonly expiresAt: DateTime.DateTime;
17}
18
19export class BootstrapCredentialError extends Data.TaggedError("BootstrapCredentialError")<{
20 readonly message: string;
21 readonly status?: 401 | 500;
22 readonly cause?: unknown;
23}> {}
24
25export interface IssuedBootstrapCredential {
26 readonly id: string;
27 readonly credential: string;
28 readonly label?: string;
29 readonly expiresAt: DateTime.Utc;
30}
31
32export type BootstrapCredentialChange =
33 | {
34 readonly type: "pairingLinkUpserted";
35 readonly pairingLink: AuthPairingLink;
36 }
37 | {
38 readonly type: "pairingLinkRemoved";
39 readonly id: string;
40 };
41
42export interface BootstrapCredentialServiceShape {
43 readonly issueOneTimeToken: (input?: {
44 readonly ttl?: Duration.Duration;
45 readonly role?: BootstrapCredentialRole;
46 readonly subject?: string;
47 readonly label?: string;
48 }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialError>;
49 readonly listActive: () => Effect.Effect<
50 ReadonlyArray<AuthPairingLink>,
51 BootstrapCredentialError
52 >;
53 readonly streamChanges: Stream.Stream<BootstrapCredentialChange>;
54 readonly revoke: (id: string) => Effect.Effect<boolean, BootstrapCredentialError>;
55 readonly consume: (credential: string) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>;
56}
57
58export class BootstrapCredentialService extends Context.Service<
59 BootstrapCredentialService,
60 BootstrapCredentialServiceShape
61>()("t3/auth/Services/BootstrapCredentialService") {}
apps/server/src/auth/Services/ServerAuth.ts deleted-86
...@@ -1,86 +0,0 @@
1import type {
2 AuthBearerBootstrapResult,
3 AuthBootstrapResult,
4 AuthClientMetadata,
5 AuthClientSession,
6 AuthCreatePairingCredentialInput,
7 AuthPairingLink,
8 AuthPairingCredentialResult,
9 AuthSessionId,
10 AuthSessionState,
11 ServerAuthDescriptor,
12 ServerAuthSessionMethod,
13 AuthWebSocketTokenResult,
14} from "@t3tools/contracts";
15import * as Data from "effect/Data";
16import * as DateTime from "effect/DateTime";
17import * as Context from "effect/Context";
18import type * as Effect from "effect/Effect";
19import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
20import type { SessionRole } from "./SessionCredentialService.ts";
21
22export interface AuthenticatedSession {
23 readonly sessionId: AuthSessionId;
24 readonly subject: string;
25 readonly method: ServerAuthSessionMethod;
26 readonly role: SessionRole;
27 readonly expiresAt?: DateTime.DateTime;
28}
29
30export class AuthError extends Data.TaggedError("AuthError")<{
31 readonly message: string;
32 readonly status?: 400 | 401 | 403 | 500;
33 readonly cause?: unknown;
34}> {}
35
36export interface ServerAuthShape {
37 readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>;
38 readonly getSessionState: (
39 request: HttpServerRequest.HttpServerRequest,
40 ) => Effect.Effect<AuthSessionState, never>;
41 readonly exchangeBootstrapCredential: (
42 credential: string,
43 requestMetadata: AuthClientMetadata,
44 ) => Effect.Effect<
45 {
46 readonly response: AuthBootstrapResult;
47 readonly sessionToken: string;
48 },
49 AuthError
50 >;
51 readonly exchangeBootstrapCredentialForBearerSession: (
52 credential: string,
53 requestMetadata: AuthClientMetadata,
54 ) => Effect.Effect<AuthBearerBootstrapResult, AuthError>;
55 readonly issuePairingCredential: (
56 input?: AuthCreatePairingCredentialInput & {
57 readonly role?: SessionRole;
58 },
59 ) => Effect.Effect<AuthPairingCredentialResult, AuthError>;
60 readonly listPairingLinks: () => Effect.Effect<ReadonlyArray<AuthPairingLink>, AuthError>;
61 readonly revokePairingLink: (id: string) => Effect.Effect<boolean, AuthError>;
62 readonly listClientSessions: (
63 currentSessionId: AuthSessionId,
64 ) => Effect.Effect<ReadonlyArray<AuthClientSession>, AuthError>;
65 readonly revokeClientSession: (
66 currentSessionId: AuthSessionId,
67 targetSessionId: AuthSessionId,
68 ) => Effect.Effect<boolean, AuthError>;
69 readonly revokeOtherClientSessions: (
70 currentSessionId: AuthSessionId,
71 ) => Effect.Effect<number, AuthError>;
72 readonly authenticateHttpRequest: (
73 request: HttpServerRequest.HttpServerRequest,
74 ) => Effect.Effect<AuthenticatedSession, AuthError>;
75 readonly authenticateWebSocketUpgrade: (
76 request: HttpServerRequest.HttpServerRequest,
77 ) => Effect.Effect<AuthenticatedSession, AuthError>;
78 readonly issueWebSocketToken: (
79 session: AuthenticatedSession,
80 ) => Effect.Effect<AuthWebSocketTokenResult, AuthError>;
81 readonly issueStartupPairingUrl: (baseUrl: string) => Effect.Effect<string, AuthError>;
82}
83
84export class ServerAuth extends Context.Service<ServerAuth, ServerAuthShape>()(
85 "t3/auth/Services/ServerAuth",
86) {}
apps/server/src/auth/Services/ServerAuthPolicy.ts deleted-11
...@@ -1,11 +0,0 @@
1import type { ServerAuthDescriptor } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import type * as Effect from "effect/Effect";
4
5export interface ServerAuthPolicyShape {
6 readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>;
7}
8
9export class ServerAuthPolicy extends Context.Service<ServerAuthPolicy, ServerAuthPolicyShape>()(
10 "t3/auth/Services/ServerAuthPolicy",
11) {}
apps/server/src/auth/Services/ServerSecretStore.ts deleted-22
...@@ -1,22 +0,0 @@
1import * as Data from "effect/Data";
2import * as Context from "effect/Context";
3import type * as Effect from "effect/Effect";
4
5export class SecretStoreError extends Data.TaggedError("SecretStoreError")<{
6 readonly message: string;
7 readonly cause?: unknown;
8}> {}
9
10export interface ServerSecretStoreShape {
11 readonly get: (name: string) => Effect.Effect<Uint8Array | null, SecretStoreError>;
12 readonly set: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>;
13 readonly getOrCreateRandom: (
14 name: string,
15 bytes: number,
16 ) => Effect.Effect<Uint8Array, SecretStoreError>;
17 readonly remove: (name: string) => Effect.Effect<void, SecretStoreError>;
18}
19
20export class ServerSecretStore extends Context.Service<ServerSecretStore, ServerSecretStoreShape>()(
21 "t3/auth/Services/ServerSecretStore",
22) {}
apps/server/src/auth/Services/SessionCredentialService.ts deleted-91
...@@ -1,91 +0,0 @@
1import type {
2 AuthClientMetadata,
3 AuthClientSession,
4 AuthSessionId,
5 ServerAuthSessionMethod,
6} from "@t3tools/contracts";
7import * as Data from "effect/Data";
8import * as DateTime from "effect/DateTime";
9import * as Duration from "effect/Duration";
10import * as Context from "effect/Context";
11import type * as Effect from "effect/Effect";
12import type * as Stream from "effect/Stream";
13
14export type SessionRole = "owner" | "client";
15
16export interface IssuedSession {
17 readonly sessionId: AuthSessionId;
18 readonly token: string;
19 readonly method: ServerAuthSessionMethod;
20 readonly client: AuthClientMetadata;
21 readonly expiresAt: DateTime.DateTime;
22 readonly role: SessionRole;
23}
24
25export interface VerifiedSession {
26 readonly sessionId: AuthSessionId;
27 readonly token: string;
28 readonly method: ServerAuthSessionMethod;
29 readonly client: AuthClientMetadata;
30 readonly expiresAt?: DateTime.DateTime;
31 readonly subject: string;
32 readonly role: SessionRole;
33}
34
35export type SessionCredentialChange =
36 | {
37 readonly type: "clientUpserted";
38 readonly clientSession: AuthClientSession;
39 }
40 | {
41 readonly type: "clientRemoved";
42 readonly sessionId: AuthSessionId;
43 };
44
45export class SessionCredentialError extends Data.TaggedError("SessionCredentialError")<{
46 readonly message: string;
47 readonly cause?: unknown;
48}> {}
49
50export interface SessionCredentialServiceShape {
51 readonly cookieName: string;
52 readonly issue: (input?: {
53 readonly ttl?: Duration.Duration;
54 readonly subject?: string;
55 readonly method?: ServerAuthSessionMethod;
56 readonly role?: SessionRole;
57 readonly client?: AuthClientMetadata;
58 }) => Effect.Effect<IssuedSession, SessionCredentialError>;
59 readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>;
60 readonly issueWebSocketToken: (
61 sessionId: AuthSessionId,
62 input?: {
63 readonly ttl?: Duration.Duration;
64 },
65 ) => Effect.Effect<
66 {
67 readonly token: string;
68 readonly expiresAt: DateTime.DateTime;
69 },
70 SessionCredentialError
71 >;
72 readonly verifyWebSocketToken: (
73 token: string,
74 ) => Effect.Effect<VerifiedSession, SessionCredentialError>;
75 readonly listActive: () => Effect.Effect<
76 ReadonlyArray<AuthClientSession>,
77 SessionCredentialError
78 >;
79 readonly streamChanges: Stream.Stream<SessionCredentialChange>;
80 readonly revoke: (sessionId: AuthSessionId) => Effect.Effect<boolean, SessionCredentialError>;
81 readonly revokeAllExcept: (
82 sessionId: AuthSessionId,
83 ) => Effect.Effect<number, SessionCredentialError>;
84 readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>;
85 readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>;
86}
87
88export class SessionCredentialService extends Context.Service<
89 SessionCredentialService,
90 SessionCredentialServiceShape
91>()("t3/auth/Services/SessionCredentialService") {}
apps/server/src/auth/SessionStore.test.ts created+243
...@@ -0,0 +1,243 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as TestClock from "effect/testing/TestClock";
7
8import type { ServerConfigShape } from "../config.ts";
9import { ServerConfig } from "../config.ts";
10import { PersistenceSqlError } from "../persistence/Errors.ts";
11import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
12import { AuthSessionRepository } from "../persistence/Services/AuthSessions.ts";
13import * as SessionStore from "./SessionStore.ts";
14import * as ServerSecretStore from "./ServerSecretStore.ts";
15
16const makeServerConfigLayer = (
17 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
18) =>
19 Layer.effect(
20 ServerConfig,
21 Effect.gen(function* () {
22 const config = yield* ServerConfig;
23 return {
24 ...config,
25 ...overrides,
26 } satisfies ServerConfigShape;
27 }),
28 ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" })));
29
30const makeSessionStoreLayer = (
31 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
32) =>
33 SessionStore.layer.pipe(
34 Layer.provide(SqlitePersistenceMemory),
35 Layer.provide(ServerSecretStore.layer),
36 Layer.provide(makeServerConfigLayer(overrides)),
37 );
38
39const repositoryFailure = new PersistenceSqlError({
40 operation: "AuthSessionRepository.getById:query",
41 detail: "sqlite is unavailable",
42});
43
44const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessionRepository, {
45 create: () => Effect.void,
46 getById: () => Effect.fail(repositoryFailure),
47 listActive: () => Effect.succeed([]),
48 revoke: () => Effect.succeed(false),
49 revokeAllExcept: () => Effect.succeed([]),
50 setLastConnectedAt: () => Effect.void,
51});
52
53const failingSessionLookupCredentialLayer = Layer.effect(
54 SessionStore.SessionStore,
55 SessionStore.make(),
56).pipe(
57 Layer.provide(failingSessionLookupRepositoryLayer),
58 Layer.provide(ServerSecretStore.layer),
59 Layer.provide(SqlitePersistenceMemory),
60 Layer.provide(makeServerConfigLayer()),
61);
62
63it.layer(NodeServices.layer)("SessionStore.layer", (it) => {
64 it.effect("issues and verifies signed browser session tokens", () =>
65 Effect.gen(function* () {
66 const sessions = yield* SessionStore.SessionStore;
67 const issued = yield* sessions.issue({
68 subject: "desktop-bootstrap",
69 scopes: ["orchestration:read", "access:write"],
70 client: {
71 label: "Desktop app",
72 deviceType: "desktop",
73 os: "macOS",
74 browser: "Electron",
75 ipAddress: "127.0.0.1",
76 },
77 });
78 const verified = yield* sessions.verify(issued.token);
79
80 expect(verified.method).toBe("browser-session-cookie");
81 expect(verified.subject).toBe("desktop-bootstrap");
82 expect(verified.scopes).toEqual(["orchestration:read", "access:write"]);
83 expect(verified.client.label).toBe("Desktop app");
84 expect(verified.client.browser).toBe("Electron");
85 expect(verified.expiresAt?.toString()).toBe(issued.expiresAt.toString());
86 }).pipe(Effect.provide(makeSessionStoreLayer())),
87 );
88 it.effect("rejects malformed session tokens", () =>
89 Effect.gen(function* () {
90 const sessions = yield* SessionStore.SessionStore;
91 const error = yield* Effect.flip(sessions.verify("not-a-session-token"));
92
93 expect(error._tag).toBe("SessionCredentialInvalidError");
94 expect(error.message).toContain("Malformed session token");
95 }).pipe(Effect.provide(makeSessionStoreLayer())),
96 );
97 it.effect("preserves repository failures while verifying session and websocket credentials", () =>
98 Effect.gen(function* () {
99 const sessions = yield* SessionStore.SessionStore;
100 const issued = yield* sessions.issue({
101 method: "bearer-access-token",
102 subject: "repository-failure",
103 });
104 const websocket = yield* sessions.issueWebSocketToken(issued.sessionId);
105
106 const sessionError = yield* Effect.flip(sessions.verify(issued.token));
107 const websocketError = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token));
108
109 expect(sessionError._tag).toBe("SessionCredentialInternalError");
110 expect(websocketError._tag).toBe("SessionCredentialInternalError");
111 expect(sessionError.cause).toBe(repositoryFailure);
112 expect(websocketError.cause).toBe(repositoryFailure);
113 }).pipe(Effect.provide(failingSessionLookupCredentialLayer)),
114 );
115 it.effect("verifies session tokens against the Effect clock", () =>
116 Effect.gen(function* () {
117 const sessions = yield* SessionStore.SessionStore;
118 const issued = yield* sessions.issue({
119 method: "bearer-access-token",
120 subject: "test-clock",
121 });
122 const verified = yield* sessions.verify(issued.token);
123
124 expect(verified.method).toBe("bearer-access-token");
125 expect(verified.subject).toBe("test-clock");
126 expect(verified.scopes).toEqual([
127 "orchestration:read",
128 "orchestration:operate",
129 "terminal:operate",
130 "review:write",
131 "relay:read",
132 ]);
133 }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))),
134 );
135
136 it.effect("rejects websocket tokens once the parent session has expired", () =>
137 Effect.gen(function* () {
138 const sessions = yield* SessionStore.SessionStore;
139 const issued = yield* sessions.issue({
140 method: "bearer-access-token",
141 subject: "short-lived",
142 ttl: Duration.seconds(1),
143 });
144 const websocket = yield* sessions.issueWebSocketToken(issued.sessionId);
145
146 yield* TestClock.adjust(Duration.seconds(2));
147
148 const error = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token));
149 expect(error.message).toContain("expired");
150 }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))),
151 );
152
153 it.effect("lists active sessions, tracks connectivity, and revokes other sessions", () =>
154 Effect.gen(function* () {
155 const sessions = yield* SessionStore.SessionStore;
156 const administrative = yield* sessions.issue({
157 subject: "desktop-bootstrap",
158 scopes: ["orchestration:read", "access:write"],
159 client: {
160 label: "Desktop app",
161 deviceType: "desktop",
162 os: "macOS",
163 browser: "Electron",
164 },
165 });
166 const client = yield* sessions.issue({
167 subject: "one-time-token",
168 scopes: ["orchestration:read"],
169 client: {
170 label: "Julius iPhone",
171 deviceType: "mobile",
172 os: "iOS",
173 browser: "Safari",
174 ipAddress: "192.168.1.88",
175 },
176 });
177
178 yield* sessions.markConnected(client.sessionId);
179 const beforeRevoke = yield* sessions.listActive();
180 const revokedCount = yield* sessions.revokeAllExcept(administrative.sessionId);
181 const afterRevoke = yield* sessions.listActive();
182 const revokedClient = yield* Effect.flip(sessions.verify(client.token));
183
184 expect(beforeRevoke).toHaveLength(2);
185 expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.connected).toBe(
186 true,
187 );
188 expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.client.label).toBe(
189 "Julius iPhone",
190 );
191 expect(
192 beforeRevoke.find((entry) => entry.sessionId === administrative.sessionId)?.client
193 .deviceType,
194 ).toBe("desktop");
195 expect(revokedCount).toBe(1);
196 expect(afterRevoke).toHaveLength(1);
197 expect(afterRevoke[0]?.sessionId).toBe(administrative.sessionId);
198 expect(revokedClient.message).toContain("revoked");
199 }).pipe(Effect.provide(makeSessionStoreLayer())),
200 );
201
202 it.effect("persists lastConnectedAt on first connect and updates it after reconnect", () =>
203 Effect.gen(function* () {
204 const sessions = yield* SessionStore.SessionStore;
205 const issued = yield* sessions.issue({
206 subject: "reconnect-test",
207 method: "bearer-access-token",
208 });
209
210 const beforeConnect = yield* sessions.listActive();
211 expect(beforeConnect[0]?.lastConnectedAt).toBeNull();
212
213 yield* TestClock.adjust(Duration.seconds(1));
214 yield* sessions.markConnected(issued.sessionId);
215 const firstConnect = yield* sessions.listActive();
216 const firstConnectedAt = firstConnect[0]?.lastConnectedAt;
217
218 expect(firstConnect[0]?.connected).toBe(true);
219 expect(firstConnectedAt).not.toBeNull();
220
221 yield* TestClock.adjust(Duration.seconds(1));
222 yield* sessions.markConnected(issued.sessionId);
223 const stillConnected = yield* sessions.listActive();
224
225 expect(stillConnected[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString());
226
227 yield* sessions.markDisconnected(issued.sessionId);
228 yield* sessions.markDisconnected(issued.sessionId);
229 const afterDisconnect = yield* sessions.listActive();
230
231 expect(afterDisconnect[0]?.connected).toBe(false);
232 expect(afterDisconnect[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString());
233
234 yield* TestClock.adjust(Duration.seconds(1));
235 yield* sessions.markConnected(issued.sessionId);
236 const afterReconnect = yield* sessions.listActive();
237
238 expect(afterReconnect[0]?.connected).toBe(true);
239 expect(afterReconnect[0]?.lastConnectedAt).not.toBeNull();
240 expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(firstConnectedAt?.toString());
241 }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))),
242 );
243});
apps/server/src/auth/SessionStore.ts created+637
...@@ -0,0 +1,637 @@
1import {
2 AuthSessionId,
3 AuthStandardClientScopes,
4 AuthEnvironmentScopes,
5 type AuthClientMetadata,
6 type AuthClientSession,
7 type AuthEnvironmentScope,
8 type ServerAuthSessionMethod,
9} from "@t3tools/contracts";
10import * as Clock from "effect/Clock";
11import * as Context from "effect/Context";
12import * as Crypto from "effect/Crypto";
13import * as Data from "effect/Data";
14import * as DateTime from "effect/DateTime";
15import * as Duration from "effect/Duration";
16import * as Effect from "effect/Effect";
17import * as Layer from "effect/Layer";
18import * as PubSub from "effect/PubSub";
19import * as Ref from "effect/Ref";
20import * as Schema from "effect/Schema";
21import * as Stream from "effect/Stream";
22import * as Option from "effect/Option";
23
24import { ServerConfig } from "../config.ts";
25import { AuthSessionRepositoryLive } from "../persistence/Layers/AuthSessions.ts";
26import { AuthSessionRepository } from "../persistence/Services/AuthSessions.ts";
27import * as ServerSecretStore from "./ServerSecretStore.ts";
28import {
29 base64UrlDecodeUtf8,
30 base64UrlEncode,
31 resolveSessionCookieName,
32 signPayload,
33 timingSafeEqualBase64Url,
34} from "./utils.ts";
35
36export interface IssuedSession {
37 readonly sessionId: AuthSessionId;
38 readonly token: string;
39 readonly method: ServerAuthSessionMethod;
40 readonly client: AuthClientMetadata;
41 readonly expiresAt: DateTime.DateTime;
42 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
43}
44
45export interface VerifiedSession {
46 readonly sessionId: AuthSessionId;
47 readonly token: string;
48 readonly method: ServerAuthSessionMethod;
49 readonly client: AuthClientMetadata;
50 readonly expiresAt?: DateTime.DateTime;
51 readonly subject: string;
52 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
53}
54
55export type SessionCredentialChange =
56 | {
57 readonly type: "clientUpserted";
58 readonly clientSession: AuthClientSession;
59 }
60 | {
61 readonly type: "clientRemoved";
62 readonly sessionId: AuthSessionId;
63 };
64
65export class SessionCredentialInvalidError extends Data.TaggedError(
66 "SessionCredentialInvalidError",
67)<{
68 readonly message: string;
69 readonly cause?: unknown;
70}> {}
71
72export class SessionCredentialInternalError extends Data.TaggedError(
73 "SessionCredentialInternalError",
74)<{
75 readonly message: string;
76 readonly cause?: unknown;
77}> {}
78
79export type SessionCredentialError = SessionCredentialInvalidError | SessionCredentialInternalError;
80
81export interface SessionStoreShape {
82 readonly cookieName: string;
83 readonly issue: (input?: {
84 readonly ttl?: Duration.Duration;
85 readonly subject?: string;
86 readonly method?: ServerAuthSessionMethod;
87 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
88 readonly client?: AuthClientMetadata;
89 }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>;
90 readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>;
91 readonly issueWebSocketToken: (
92 sessionId: AuthSessionId,
93 input?: {
94 readonly ttl?: Duration.Duration;
95 },
96 ) => Effect.Effect<
97 {
98 readonly token: string;
99 readonly expiresAt: DateTime.DateTime;
100 },
101 SessionCredentialInternalError
102 >;
103 readonly verifyWebSocketToken: (
104 token: string,
105 ) => Effect.Effect<VerifiedSession, SessionCredentialError>;
106 readonly listActive: () => Effect.Effect<
107 ReadonlyArray<AuthClientSession>,
108 SessionCredentialInternalError
109 >;
110 readonly streamChanges: Stream.Stream<SessionCredentialChange>;
111 readonly revoke: (
112 sessionId: AuthSessionId,
113 ) => Effect.Effect<boolean, SessionCredentialInternalError>;
114 readonly revokeAllExcept: (
115 sessionId: AuthSessionId,
116 ) => Effect.Effect<number, SessionCredentialInternalError>;
117 readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>;
118 readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>;
119}
120
121export class SessionStore extends Context.Service<SessionStore, SessionStoreShape>()(
122 "t3/auth/SessionStore",
123) {}
124
125const SIGNING_SECRET_NAME = "server-signing-key";
126const DEFAULT_SESSION_TTL = Duration.days(30);
127const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5);
128
129const SessionClaims = Schema.Struct({
130 v: Schema.Literal(1),
131 kind: Schema.Literal("session"),
132 sid: AuthSessionId,
133 sub: Schema.String,
134 scopes: AuthEnvironmentScopes,
135 method: Schema.Literals(["browser-session-cookie", "bearer-access-token"]),
136 iat: Schema.Number,
137 exp: Schema.Number,
138});
139type SessionClaims = typeof SessionClaims.Type;
140
141const WebSocketClaims = Schema.Struct({
142 v: Schema.Literal(1),
143 kind: Schema.Literal("websocket"),
144 sid: AuthSessionId,
145 iat: Schema.Number,
146 exp: Schema.Number,
147});
148type WebSocketClaims = typeof WebSocketClaims.Type;
149
150const decodeSessionClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(SessionClaims));
151const decodeWebSocketClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(WebSocketClaims));
152
153function createDefaultClientMetadata(): AuthClientMetadata {
154 return {
155 deviceType: "unknown",
156 };
157}
158
159function toClientMetadata(record: {
160 readonly label: string | null;
161 readonly ipAddress: string | null;
162 readonly userAgent: string | null;
163 readonly deviceType: AuthClientMetadata["deviceType"];
164 readonly os: string | null;
165 readonly browser: string | null;
166}): AuthClientMetadata {
167 return {
168 ...(record.label ? { label: record.label } : {}),
169 ...(record.ipAddress ? { ipAddress: record.ipAddress } : {}),
170 ...(record.userAgent ? { userAgent: record.userAgent } : {}),
171 deviceType: record.deviceType,
172 ...(record.os ? { os: record.os } : {}),
173 ...(record.browser ? { browser: record.browser } : {}),
174 };
175}
176
177function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthClientSession {
178 return {
179 ...input,
180 current: false,
181 };
182}
183
184const toSessionCredentialInternalError = (message: string) => (cause: unknown) =>
185 new SessionCredentialInternalError({
186 message,
187 cause,
188 });
189
190export const make = Effect.fn("makeSessionStore")(function* () {
191 const crypto = yield* Crypto.Crypto;
192 const serverConfig = yield* ServerConfig;
193 const secretStore = yield* ServerSecretStore.ServerSecretStore;
194 const authSessions = yield* AuthSessionRepository;
195 const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32);
196 const connectedSessionsRef = yield* Ref.make(new Map<string, number>());
197 const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>();
198 const cookieName = resolveSessionCookieName({
199 mode: serverConfig.mode,
200 port: serverConfig.port,
201 });
202
203 const emitUpsert = (clientSession: AuthClientSession) =>
204 PubSub.publish(changesPubSub, {
205 type: "clientUpserted",
206 clientSession,
207 }).pipe(Effect.asVoid);
208
209 const emitRemoved = (sessionId: AuthSessionId) =>
210 PubSub.publish(changesPubSub, {
211 type: "clientRemoved",
212 sessionId,
213 }).pipe(Effect.asVoid);
214
215 const loadActiveSession = (sessionId: AuthSessionId) =>
216 Effect.gen(function* () {
217 const row = yield* authSessions.getById({ sessionId });
218 if (Option.isNone(row) || row.value.revokedAt !== null) {
219 return Option.none<AuthClientSession>();
220 }
221
222 const connectedSessions = yield* Ref.get(connectedSessionsRef);
223 return Option.some(
224 toAuthClientSession({
225 sessionId: row.value.sessionId,
226 subject: row.value.subject,
227 scopes: row.value.scopes,
228 method: row.value.method,
229 client: toClientMetadata(row.value.client),
230 issuedAt: row.value.issuedAt,
231 expiresAt: row.value.expiresAt,
232 lastConnectedAt: row.value.lastConnectedAt,
233 connected: connectedSessions.has(row.value.sessionId),
234 }),
235 );
236 });
237
238 const markConnected: SessionStoreShape["markConnected"] = (sessionId) =>
239 Ref.modify(connectedSessionsRef, (current) => {
240 const next = new Map(current);
241 const wasDisconnected = !next.has(sessionId);
242 next.set(sessionId, (next.get(sessionId) ?? 0) + 1);
243 return [wasDisconnected, next] as const;
244 }).pipe(
245 Effect.flatMap((wasDisconnected) =>
246 wasDisconnected
247 ? DateTime.now.pipe(
248 Effect.flatMap((lastConnectedAt) =>
249 authSessions.setLastConnectedAt({
250 sessionId,
251 lastConnectedAt,
252 }),
253 ),
254 )
255 : Effect.void,
256 ),
257 Effect.flatMap(() => loadActiveSession(sessionId)),
258 Effect.flatMap((session) =>
259 Option.isSome(session) ? emitUpsert(session.value) : Effect.void,
260 ),
261 Effect.catchCause((cause) =>
262 Effect.logError("Failed to publish connected-session auth update.").pipe(
263 Effect.annotateLogs({
264 sessionId,
265 cause,
266 }),
267 ),
268 ),
269 Effect.withSpan("SessionStore.markConnected"),
270 );
271
272 const markDisconnected: SessionStoreShape["markDisconnected"] = (sessionId) =>
273 Ref.update(connectedSessionsRef, (current) => {
274 const next = new Map(current);
275 const remaining = (next.get(sessionId) ?? 0) - 1;
276 if (remaining > 0) {
277 next.set(sessionId, remaining);
278 } else {
279 next.delete(sessionId);
280 }
281 return next;
282 }).pipe(
283 Effect.flatMap(() => loadActiveSession(sessionId)),
284 Effect.flatMap((session) =>
285 Option.isSome(session) ? emitUpsert(session.value) : Effect.void,
286 ),
287 Effect.catchCause((cause) =>
288 Effect.logError("Failed to publish disconnected-session auth update.").pipe(
289 Effect.annotateLogs({
290 sessionId,
291 cause,
292 }),
293 ),
294 ),
295 Effect.withSpan("SessionStore.markDisconnected"),
296 );
297
298 const encodeClaims = Schema.encodeEffect(Schema.fromJsonString(SessionClaims));
299 const issue: SessionStoreShape["issue"] = Effect.fn("SessionStore.issue")(
300 function* (input) {
301 const sessionId = AuthSessionId.make(yield* crypto.randomUUIDv4);
302 const issuedAt = yield* DateTime.now;
303 const expiresAt = DateTime.add(issuedAt, {
304 milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL),
305 });
306 const claims: SessionClaims = {
307 v: 1,
308 kind: "session",
309 sid: sessionId,
310 sub: input?.subject ?? "browser",
311 scopes: input?.scopes ?? AuthStandardClientScopes,
312 method: input?.method ?? "browser-session-cookie",
313 iat: issuedAt.epochMilliseconds,
314 exp: expiresAt.epochMilliseconds,
315 };
316
317 const encodedPayload = yield* encodeClaims(claims).pipe(
318 Effect.map(base64UrlEncode),
319 Effect.mapError(
320 (cause) =>
321 new SessionCredentialInternalError({ message: "Failed to encode claims", cause }),
322 ),
323 );
324 const signature = signPayload(encodedPayload, signingSecret);
325 const client = input?.client ?? createDefaultClientMetadata();
326 yield* authSessions.create({
327 sessionId,
328 subject: claims.sub,
329 scopes: claims.scopes,
330 method: claims.method,
331 client: {
332 label: client.label ?? null,
333 ipAddress: client.ipAddress ?? null,
334 userAgent: client.userAgent ?? null,
335 deviceType: client.deviceType,
336 os: client.os ?? null,
337 browser: client.browser ?? null,
338 },
339 issuedAt,
340 expiresAt,
341 });
342 yield* emitUpsert(
343 toAuthClientSession({
344 sessionId,
345 subject: claims.sub,
346 scopes: claims.scopes,
347 method: claims.method,
348 client,
349 issuedAt,
350 expiresAt,
351 lastConnectedAt: null,
352 connected: false,
353 }),
354 );
355
356 return {
357 sessionId,
358 token: `${encodedPayload}.${signature}`,
359 method: claims.method,
360 client,
361 expiresAt: expiresAt,
362 scopes: claims.scopes,
363 } satisfies IssuedSession;
364 },
365 Effect.mapError(toSessionCredentialInternalError("Failed to issue session credential.")),
366 );
367
368 const verify: SessionStoreShape["verify"] = Effect.fn("SessionStore.verify")(
369 function* (token) {
370 const [encodedPayload, signature] = token.split(".");
371 if (!encodedPayload || !signature) {
372 return yield* new SessionCredentialInvalidError({
373 message: "Malformed session token.",
374 });
375 }
376
377 const expectedSignature = signPayload(encodedPayload, signingSecret);
378 if (!timingSafeEqualBase64Url(signature, expectedSignature)) {
379 return yield* new SessionCredentialInvalidError({
380 message: "Invalid session token signature.",
381 });
382 }
383
384 const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
385 Effect.mapError(
386 (cause) =>
387 new SessionCredentialInvalidError({
388 message: "Invalid session token payload.",
389 cause,
390 }),
391 ),
392 );
393
394 const now = yield* Clock.currentTimeMillis;
395 if (claims.exp <= now) {
396 return yield* new SessionCredentialInvalidError({
397 message: "Session token expired.",
398 });
399 }
400
401 const row = yield* authSessions.getById({ sessionId: claims.sid });
402 if (Option.isNone(row)) {
403 return yield* new SessionCredentialInvalidError({
404 message: "Unknown session token.",
405 });
406 }
407 if (row.value.revokedAt !== null) {
408 return yield* new SessionCredentialInvalidError({
409 message: "Session token revoked.",
410 });
411 }
412
413 const expiresAt = DateTime.make(claims.exp);
414 if (Option.isNone(expiresAt)) {
415 return yield* new SessionCredentialInvalidError({
416 message: "Invalid `exp` claim",
417 });
418 }
419
420 return {
421 sessionId: claims.sid,
422 token,
423 method: claims.method,
424 client: toClientMetadata(row.value.client),
425 expiresAt: expiresAt.value,
426 subject: claims.sub,
427 scopes: claims.scopes,
428 } satisfies VerifiedSession;
429 },
430 Effect.mapError((cause) =>
431 cause._tag === "SessionCredentialInvalidError"
432 ? cause
433 : new SessionCredentialInternalError({
434 message: "Failed to verify session credential.",
435 cause,
436 }),
437 ),
438 );
439
440 const encodeWsClaims = Schema.encodeEffect(Schema.fromJsonString(WebSocketClaims));
441 const issueWebSocketToken: SessionStoreShape["issueWebSocketToken"] = Effect.fn(
442 "SessionStore.issueWebSocketToken",
443 )(
444 function* (sessionId, input) {
445 const issuedAt = yield* DateTime.now;
446 const expiresAt = DateTime.add(issuedAt, {
447 milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL),
448 });
449 const claims: WebSocketClaims = {
450 v: 1,
451 kind: "websocket",
452 sid: sessionId,
453 iat: issuedAt.epochMilliseconds,
454 exp: expiresAt.epochMilliseconds,
455 };
456 const encodedPayload = yield* encodeWsClaims(claims).pipe(
457 Effect.map(base64UrlEncode),
458 Effect.mapError(
459 (cause) =>
460 new SessionCredentialInternalError({ message: "Failed to encode claims", cause }),
461 ),
462 );
463 const signature = signPayload(encodedPayload, signingSecret);
464 return {
465 token: `${encodedPayload}.${signature}`,
466 expiresAt,
467 };
468 },
469 Effect.mapError(toSessionCredentialInternalError("Failed to issue websocket token.")),
470 );
471
472 const verifyWebSocketToken: SessionStoreShape["verifyWebSocketToken"] = Effect.fn(
473 "SessionStore.verifyWebSocketToken",
474 )(
475 function* (token) {
476 const [encodedPayload, signature] = token.split(".");
477 if (!encodedPayload || !signature) {
478 return yield* new SessionCredentialInvalidError({
479 message: "Malformed websocket token.",
480 });
481 }
482
483 const expectedSignature = signPayload(encodedPayload, signingSecret);
484 if (!timingSafeEqualBase64Url(signature, expectedSignature)) {
485 return yield* new SessionCredentialInvalidError({
486 message: "Invalid websocket token signature.",
487 });
488 }
489
490 const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
491 Effect.mapError(
492 (cause) =>
493 new SessionCredentialInvalidError({
494 message: "Invalid websocket token payload.",
495 cause,
496 }),
497 ),
498 );
499
500 const now = yield* Clock.currentTimeMillis;
501 if (claims.exp <= now) {
502 return yield* new SessionCredentialInvalidError({
503 message: "Websocket token expired.",
504 });
505 }
506
507 const row = yield* authSessions.getById({ sessionId: claims.sid });
508 if (Option.isNone(row)) {
509 return yield* new SessionCredentialInvalidError({
510 message: "Unknown websocket session.",
511 });
512 }
513 if (row.value.expiresAt.epochMilliseconds <= now) {
514 return yield* new SessionCredentialInvalidError({
515 message: "Websocket session expired.",
516 });
517 }
518 if (row.value.revokedAt !== null) {
519 return yield* new SessionCredentialInvalidError({
520 message: "Websocket session revoked.",
521 });
522 }
523
524 return {
525 sessionId: row.value.sessionId,
526 token,
527 method: row.value.method,
528 client: toClientMetadata(row.value.client),
529 expiresAt: row.value.expiresAt,
530 subject: row.value.subject,
531 scopes: row.value.scopes,
532 } satisfies VerifiedSession;
533 },
534 Effect.mapError((cause) =>
535 cause._tag === "SessionCredentialInvalidError"
536 ? cause
537 : new SessionCredentialInternalError({
538 message: "Failed to verify websocket token.",
539 cause,
540 }),
541 ),
542 );
543
544 const listActive: SessionStoreShape["listActive"] = Effect.fn("SessionStore.listActive")(
545 function* () {
546 const now = yield* DateTime.now;
547 const connectedSessions = yield* Ref.get(connectedSessionsRef);
548 const rows = yield* authSessions.listActive({ now });
549
550 return rows.map((row) =>
551 toAuthClientSession({
552 sessionId: row.sessionId,
553 subject: row.subject,
554 scopes: row.scopes,
555 method: row.method,
556 client: toClientMetadata(row.client),
557 issuedAt: row.issuedAt,
558 expiresAt: row.expiresAt,
559 lastConnectedAt: row.lastConnectedAt,
560 connected: connectedSessions.has(row.sessionId),
561 }),
562 );
563 },
564 Effect.mapError(toSessionCredentialInternalError("Failed to list active sessions.")),
565 );
566
567 const revoke: SessionStoreShape["revoke"] = Effect.fn("SessionStore.revoke")(
568 function* (sessionId) {
569 const revokedAt = yield* DateTime.now;
570 const revoked = yield* authSessions.revoke({
571 sessionId,
572 revokedAt,
573 });
574 if (revoked) {
575 yield* Ref.update(connectedSessionsRef, (current) => {
576 const next = new Map(current);
577 next.delete(sessionId);
578 return next;
579 });
580 yield* emitRemoved(sessionId);
581 }
582 return revoked;
583 },
584 Effect.mapError(toSessionCredentialInternalError("Failed to revoke session.")),
585 );
586
587 const revokeAllExcept: SessionStoreShape["revokeAllExcept"] = Effect.fn(
588 "SessionStore.revokeAllExcept",
589 )(
590 function* (sessionId) {
591 const revokedAt = yield* DateTime.now;
592 const revokedSessionIds = yield* authSessions.revokeAllExcept({
593 currentSessionId: sessionId,
594 revokedAt,
595 });
596 if (revokedSessionIds.length > 0) {
597 yield* Ref.update(connectedSessionsRef, (current) => {
598 const next = new Map(current);
599 for (const revokedSessionId of revokedSessionIds) {
600 next.delete(revokedSessionId);
601 }
602 return next;
603 });
604 yield* Effect.forEach(
605 revokedSessionIds,
606 (revokedSessionId) => emitRemoved(revokedSessionId),
607 {
608 concurrency: "unbounded",
609 discard: true,
610 },
611 );
612 }
613 return revokedSessionIds.length;
614 },
615 Effect.mapError(toSessionCredentialInternalError("Failed to revoke other sessions.")),
616 );
617
618 return {
619 cookieName,
620 issue,
621 verify,
622 issueWebSocketToken,
623 verifyWebSocketToken,
624 listActive,
625 get streamChanges() {
626 return Stream.fromPubSub(changesPubSub);
627 },
628 revoke,
629 revokeAllExcept,
630 markConnected,
631 markDisconnected,
632 } satisfies SessionStoreShape;
633});
634
635export const layer = Layer.effect(SessionStore, make()).pipe(
636 Layer.provideMerge(AuthSessionRepositoryLive),
637);
apps/server/src/auth/http.ts+340-234
...@@ -1,265 +1,371 @@...@@ -1,265 +1,371 @@
1import {1import {
2 type AuthBearerBootstrapResult,2 AuthAccessReadScope,
3 AuthBootstrapInput,3 AuthAccessWriteScope,
4 AuthCreatePairingCredentialInput,4 AuthStandardClientScopes,
5 AuthRevokeClientSessionInput,5 AuthOrchestrationOperateScope,
6 AuthRevokePairingLinkInput,6 AuthOrchestrationReadScope,
7 type AuthWebSocketTokenResult,7 AuthRelayReadScope,
8 AuthRelayWriteScope,
9 AuthReviewWriteScope,
10 AuthTerminalOperateScope,
11 EnvironmentAuthInvalidError,
12 type EnvironmentAuthInvalidReason,
13 EnvironmentHttpApi,
14 EnvironmentInternalError,
15 type EnvironmentInternalErrorReason,
16 EnvironmentOperationForbiddenError,
17 EnvironmentRequestInvalidError,
18 type EnvironmentRequestInvalidReason,
19 EnvironmentScopeRequiredError,
20 EnvironmentAuthenticatedAuth,
21 EnvironmentAuthenticatedPrincipal,
8} from "@t3tools/contracts";22} from "@t3tools/contracts";
23import type { AuthEnvironmentScope } from "@t3tools/contracts";
24import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope";
25import { causeErrorTag } from "@t3tools/shared/observability";
9import * as DateTime from "effect/DateTime";26import * as DateTime from "effect/DateTime";
10import * as Effect from "effect/Effect";27import * as Effect from "effect/Effect";
11import * as Schema from "effect/Schema";28import * as Layer from "effect/Layer";
12import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";29import * as Cookies from "effect/unstable/http/Cookies";
30import * as HttpEffect from "effect/unstable/http/HttpEffect";
31import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
32import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
1333
14import { AuthError, ServerAuth } from "./Services/ServerAuth.ts";34import * as EnvironmentAuth from "./EnvironmentAuth.ts";
15import { SessionCredentialService } from "./Services/SessionCredentialService.ts";35import * as SessionStore from "./SessionStore.ts";
16import { deriveAuthClientMetadata } from "./utils.ts";36import { deriveAuthClientMetadata } from "./utils.ts";
17import { browserApiCorsHeaders } from "../httpCors.ts";
1837
19export const respondToAuthError = (error: AuthError) =>38export const currentEnvironmentTraceId = Effect.currentParentSpan.pipe(
20 Effect.gen(function* () {39 Effect.map((span) => span.traceId),
21 if ((error.status ?? 500) >= 500) {40 Effect.orElseSucceed(() => "unavailable"),
22 yield* Effect.logError("auth route failed", {41);
23 message: error.message,
24 cause: error.cause,
25 });
26 }
27 return HttpServerResponse.jsonUnsafe(
28 {
29 error: error.message,
30 },
31 { status: error.status ?? 500, headers: browserApiCorsHeaders },
32 );
33 });
3442
35export const authSessionRouteLayer = HttpRouter.add(43export function annotateEnvironmentRequest(endpoint: string) {
36 "GET",44 return Effect.gen(function* () {
37 "/api/auth/session",
38 Effect.gen(function* () {
39 const request = yield* HttpServerRequest.HttpServerRequest;45 const request = yield* HttpServerRequest.HttpServerRequest;
40 const serverAuth = yield* ServerAuth;46 const url = HttpServerRequest.toURL(request);
41 const session = yield* serverAuth.getSessionState(request);47 const traceId = yield* currentEnvironmentTraceId;
42 return HttpServerResponse.jsonUnsafe(session, {48
43 status: 200,49 yield* Effect.addFinalizer((exit) =>
44 headers: browserApiCorsHeaders,50 exit._tag === "Failure"
51 ? Effect.logWarning("environment api request failed", {
52 endpoint,
53 traceId,
54 errorTag: causeErrorTag(exit.cause),
55 cause: exit.cause,
56 })
57 : Effect.void,
58 );
59 yield* Effect.annotateLogsScoped({ "environment.endpoint": endpoint, traceId });
60 yield* Effect.annotateCurrentSpan({
61 "environment.endpoint": endpoint,
62 "http.request.method": request.method,
63 "url.path": url._tag === "Some" ? url.value.pathname : "unknown",
45 });64 });
46 }),65 });
47);66}
4867
49const PairingCredentialRequestHeaders = Schema.Struct({68export function failEnvironmentAuthInvalid(reason: EnvironmentAuthInvalidReason) {
50 "content-length": Schema.optionalKey(Schema.String),69 return currentEnvironmentTraceId.pipe(
51 "content-type": Schema.optionalKey(Schema.String),70 Effect.flatMap((traceId) =>
52 "transfer-encoding": Schema.optionalKey(Schema.String),71 Effect.fail(new EnvironmentAuthInvalidError({ code: "auth_invalid", reason, traceId })),
53});72 ),
73 );
74}
5475
55function hasRequestBody(headers: typeof PairingCredentialRequestHeaders.Type) {76export function failEnvironmentInvalidRequest(reason: EnvironmentRequestInvalidReason) {
56 const contentLengthHeader = headers["content-length"];77 return currentEnvironmentTraceId.pipe(
57 if (typeof contentLengthHeader === "string") {78 Effect.flatMap((traceId) =>
58 const contentLength = Number.parseInt(contentLengthHeader, 10);79 Effect.fail(new EnvironmentRequestInvalidError({ code: "invalid_request", reason, traceId })),
59 if (Number.isFinite(contentLength)) {80 ),
60 return contentLength > 0;81 );
61 }
62 }
63 return typeof headers["transfer-encoding"] === "string";
64}82}
6583
66export const authBootstrapRouteLayer = HttpRouter.add(84export function failEnvironmentScopeRequired(requiredScope: AuthEnvironmentScope) {
67 "POST",85 return currentEnvironmentTraceId.pipe(
68 "/api/auth/bootstrap",86 Effect.flatMap((traceId) =>
69 Effect.gen(function* () {87 Effect.fail(
70 const request = yield* HttpServerRequest.HttpServerRequest;88 new EnvironmentScopeRequiredError({
71 const serverAuth = yield* ServerAuth;89 code: "insufficient_scope",
72 const sessions = yield* SessionCredentialService;90 requiredScope,
73 const payload = yield* HttpServerRequest.schemaBodyJson(AuthBootstrapInput).pipe(91 traceId,
74 Effect.mapError(92 }),
75 (cause) =>
76 new AuthError({
77 message: "Invalid bootstrap payload.",
78 status: 400,
79 cause,
80 }),
81 ),93 ),
82 );94 ),
83 const result = yield* serverAuth.exchangeBootstrapCredential(95 );
84 payload.credential,96}
85 deriveAuthClientMetadata({ request }),
86 );
87
88 return yield* HttpServerResponse.jsonUnsafe(result.response, {
89 status: 200,
90 headers: browserApiCorsHeaders,
91 }).pipe(
92 HttpServerResponse.setCookie(sessions.cookieName, result.sessionToken, {
93 expires: DateTime.toDate(result.response.expiresAt),
94 httpOnly: true,
95 path: "/",
96 sameSite: "lax",
97 }),
98 );
99 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
100);
10197
102export const authBearerBootstrapRouteLayer = HttpRouter.add(98function failEnvironmentOperationForbidden(reason: "current_session_revoke_not_allowed") {
103 "POST",99 return currentEnvironmentTraceId.pipe(
104 "/api/auth/bootstrap/bearer",100 Effect.flatMap((traceId) =>
105 Effect.gen(function* () {101 Effect.fail(
106 const request = yield* HttpServerRequest.HttpServerRequest;102 new EnvironmentOperationForbiddenError({
107 const serverAuth = yield* ServerAuth;103 code: "operation_forbidden",
108 const payload = yield* HttpServerRequest.schemaBodyJson(AuthBootstrapInput).pipe(104 reason,
109 Effect.mapError(105 traceId,
110 (cause) =>106 }),
111 new AuthError({
112 message: "Invalid bootstrap payload.",
113 status: 400,
114 cause,
115 }),
116 ),107 ),
117 );108 ),
118 const result = yield* serverAuth.exchangeBootstrapCredentialForBearerSession(109 );
119 payload.credential,110}
120 deriveAuthClientMetadata({ request }),
121 );
122 return HttpServerResponse.jsonUnsafe(result satisfies AuthBearerBootstrapResult, {
123 status: 200,
124 headers: browserApiCorsHeaders,
125 });
126 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
127);
128
129export const authWebSocketTokenRouteLayer = HttpRouter.add(
130 "POST",
131 "/api/auth/ws-token",
132 Effect.gen(function* () {
133 const request = yield* HttpServerRequest.HttpServerRequest;
134 const serverAuth = yield* ServerAuth;
135 const session = yield* serverAuth.authenticateHttpRequest(request);
136 const result = yield* serverAuth.issueWebSocketToken(session);
137 return HttpServerResponse.jsonUnsafe(result satisfies AuthWebSocketTokenResult, {
138 status: 200,
139 headers: browserApiCorsHeaders,
140 });
141 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
142);
143111
144export const authPairingCredentialRouteLayer = HttpRouter.add(112export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason, error?: unknown) {
145 "POST",113 return Effect.gen(function* () {
146 "/api/auth/pairing-token",114 const traceId = yield* currentEnvironmentTraceId;
147 Effect.gen(function* () {115 if (error !== undefined) {
148 const serverAuth = yield* ServerAuth;116 yield* Effect.logError("environment api operation failed", {
149 const request = yield* HttpServerRequest.HttpServerRequest;117 reason,
150 const session = yield* serverAuth.authenticateHttpRequest(request);118 traceId,
151 if (session.role !== "owner") {119 cause: error,
152 return yield* new AuthError({
153 message: "Only owner sessions can create pairing credentials.",
154 status: 403,
155 });120 });
156 }121 }
157 const headers = yield* HttpServerRequest.schemaHeaders(PairingCredentialRequestHeaders).pipe(122 return yield* new EnvironmentInternalError({ code: "internal_error", reason, traceId });
158 Effect.mapError(123 });
159 (cause) =>124}
160 new AuthError({
161 message: "Invalid pairing credential request headers.",
162 status: 400,
163 cause,
164 }),
165 ),
166 );
167 const payload = hasRequestBody(headers)
168 ? yield* HttpServerRequest.schemaBodyJson(AuthCreatePairingCredentialInput).pipe(
169 Effect.mapError(
170 (cause) =>
171 new AuthError({
172 message: "Invalid pairing credential payload.",
173 status: 400,
174 cause,
175 }),
176 ),
177 )
178 : {};
179 const result = yield* serverAuth.issuePairingCredential(payload);
180 return HttpServerResponse.jsonUnsafe(result, { status: 200 });
181 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
182);
183125
184const authenticateOwnerSession = Effect.gen(function* () {126export const requireEnvironmentScope = Effect.fn("environment.auth.requireScope")(function* (
185 const request = yield* HttpServerRequest.HttpServerRequest;127 scope: AuthEnvironmentScope,
186 const serverAuth = yield* ServerAuth;128) {
187 const session = yield* serverAuth.authenticateHttpRequest(request);129 const session = yield* EnvironmentAuthenticatedPrincipal;
188 if (session.role !== "owner") {130 if (!session.scopes.has(scope)) {
189 return yield* new AuthError({131 return yield* failEnvironmentScopeRequired(scope);
190 message: "Only owner sessions can manage network access.",
191 status: 403,
192 });
193 }132 }
194 return { serverAuth, session } as const;133 return session;
195});134});
196135
197export const authPairingLinksRouteLayer = HttpRouter.add(136export const environmentAuthenticatedAuthLayer = Layer.effect(
198 "GET",137 EnvironmentAuthenticatedAuth,
199 "/api/auth/pairing-links",
200 Effect.gen(function* () {138 Effect.gen(function* () {
201 const { serverAuth } = yield* authenticateOwnerSession;139 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
202 const pairingLinks = yield* serverAuth.listPairingLinks();140 return (httpEffect) =>
203 return HttpServerResponse.jsonUnsafe(pairingLinks, { status: 200 });141 Effect.gen(function* () {
204 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),142 const request = yield* HttpServerRequest.HttpServerRequest;
205);143 const session = yield* serverAuth.authenticateHttpRequest(request).pipe(
206144 Effect.catchTags({
207export const authPairingLinksRevokeRouteLayer = HttpRouter.add(145 ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason),
208 "POST",146 ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error),
209 "/api/auth/pairing-links/revoke",
210 Effect.gen(function* () {
211 const { serverAuth } = yield* authenticateOwnerSession;
212 const payload = yield* HttpServerRequest.schemaBodyJson(AuthRevokePairingLinkInput).pipe(
213 Effect.mapError(
214 (cause) =>
215 new AuthError({
216 message: "Invalid revoke pairing link payload.",
217 status: 400,
218 cause,
219 }),147 }),
220 ),148 );
221 );149 return yield* httpEffect.pipe(
222 const revoked = yield* serverAuth.revokePairingLink(payload.id);150 Effect.provideService(EnvironmentAuthenticatedPrincipal, {
223 return HttpServerResponse.jsonUnsafe({ revoked }, { status: 200 });151 ...session,
224 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),152 scopes: new Set(session.scopes),
153 }),
154 );
155 });
156 }),
225);157);
226158
227export const authClientsRouteLayer = HttpRouter.add(159export const authHttpApiLayer = HttpApiBuilder.group(
228 "GET",160 EnvironmentHttpApi,
229 "/api/auth/clients",161 "auth",
230 Effect.gen(function* () {162 Effect.fnUntraced(function* (handlers) {
231 const { serverAuth, session } = yield* authenticateOwnerSession;163 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
232 const clients = yield* serverAuth.listClientSessions(session.sessionId);164 const sessions = yield* SessionStore.SessionStore;
233 return HttpServerResponse.jsonUnsafe(clients, { status: 200 });
234 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
235);
236165
237export const authClientsRevokeRouteLayer = HttpRouter.add(166 return handlers
238 "POST",167 .handle(
239 "/api/auth/clients/revoke",168 "session",
240 Effect.gen(function* () {169 Effect.fn("environment.auth.session")(
241 const { serverAuth, session } = yield* authenticateOwnerSession;170 function* (args) {
242 const payload = yield* HttpServerRequest.schemaBodyJson(AuthRevokeClientSessionInput).pipe(171 yield* annotateEnvironmentRequest(args.endpoint.name);
243 Effect.mapError(172 const request = yield* HttpServerRequest.HttpServerRequest;
244 (cause) =>173 return yield* serverAuth.getSessionState(request);
245 new AuthError({174 },
246 message: "Invalid revoke client payload.",175 Effect.catchTag("ServerAuthInternalError", (error) =>
247 status: 400,176 failEnvironmentInternal("internal_error", error),
248 cause,177 ),
249 }),178 ),
250 ),179 )
251 );180 .handle(
252 const revoked = yield* serverAuth.revokeClientSession(session.sessionId, payload.sessionId);181 "browserSession",
253 return HttpServerResponse.jsonUnsafe({ revoked }, { status: 200 });182 Effect.fn("environment.auth.browserSession")(
254 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),183 function* (args) {
255);184 yield* annotateEnvironmentRequest(args.endpoint.name);
185 const request = yield* HttpServerRequest.HttpServerRequest;
186 const result = yield* serverAuth.createBrowserSession(
187 args.payload.credential,
188 deriveAuthClientMetadata({ request }),
189 );
190 const sessionCookies = yield* Effect.fromResult(
191 Cookies.set(Cookies.empty, sessions.cookieName, result.sessionToken, {
192 expires: DateTime.toDate(result.response.expiresAt),
193 httpOnly: true,
194 path: "/",
195 sameSite: "lax",
196 }),
197 ).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed")));
256198
257export const authClientsRevokeOthersRouteLayer = HttpRouter.add(199 yield* HttpEffect.appendPreResponseHandler((_request, response) =>
258 "POST",200 Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)),
259 "/api/auth/clients/revoke-others",201 );
260 Effect.gen(function* () {202 return result.response;
261 const { serverAuth, session } = yield* authenticateOwnerSession;203 },
262 const revokedCount = yield* serverAuth.revokeOtherClientSessions(session.sessionId);204 Effect.catchTags({
263 return HttpServerResponse.jsonUnsafe({ revokedCount }, { status: 200 });205 ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason),
264 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),206 ServerAuthInternalError: (error) =>
207 failEnvironmentInternal("browser_session_issuance_failed", error),
208 }),
209 ),
210 )
211 .handle(
212 "token",
213 Effect.fn("environment.auth.token")(
214 function* (args) {
215 yield* annotateEnvironmentRequest(args.endpoint.name);
216 const request = yield* HttpServerRequest.HttpServerRequest;
217 const requestedScopes =
218 args.payload.scope === undefined
219 ? undefined
220 : parseAllowedOAuthScope({
221 value: args.payload.scope,
222 allowedScopes: new Set<AuthEnvironmentScope>([
223 AuthOrchestrationReadScope,
224 AuthOrchestrationOperateScope,
225 AuthTerminalOperateScope,
226 AuthReviewWriteScope,
227 AuthAccessReadScope,
228 AuthAccessWriteScope,
229 AuthRelayReadScope,
230 AuthRelayWriteScope,
231 ]),
232 });
233 if (requestedScopes === null)
234 return yield* failEnvironmentInvalidRequest("invalid_scope");
235 return yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
236 args.payload.subject_token,
237 requestedScopes,
238 deriveAuthClientMetadata({
239 request,
240 presented: {
241 ...(args.payload.client_label ? { label: args.payload.client_label } : {}),
242 ...(args.payload.client_device_type
243 ? { deviceType: args.payload.client_device_type }
244 : {}),
245 ...(args.payload.client_os ? { os: args.payload.client_os } : {}),
246 },
247 }),
248 );
249 },
250 Effect.catchTags({
251 ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason),
252 ServerAuthInvalidRequestError: (error) => failEnvironmentInvalidRequest(error.reason),
253 ServerAuthInternalError: (error) =>
254 failEnvironmentInternal("access_token_issuance_failed", error),
255 }),
256 ),
257 )
258 .handle(
259 "webSocketTicket",
260 Effect.fn("environment.auth.webSocketTicket")(
261 function* (args) {
262 yield* annotateEnvironmentRequest(args.endpoint.name);
263 const session = yield* EnvironmentAuthenticatedPrincipal;
264 return yield* serverAuth.issueWebSocketTicket(session);
265 },
266 Effect.catchTag("ServerAuthInternalError", (error) =>
267 failEnvironmentInternal("websocket_ticket_issuance_failed", error),
268 ),
269 ),
270 )
271 .handle(
272 "pairingCredential",
273 Effect.fn("environment.auth.pairingCredential")(
274 function* (args) {
275 yield* annotateEnvironmentRequest(args.endpoint.name);
276 const session = yield* requireEnvironmentScope(AuthAccessWriteScope);
277 const delegatedScopes = args.payload.scopes ?? AuthStandardClientScopes;
278 if (
279 delegatedScopes.length === 0 ||
280 new Set<AuthEnvironmentScope>(delegatedScopes).size !== delegatedScopes.length
281 ) {
282 return yield* failEnvironmentInvalidRequest("invalid_scope");
283 }
284 for (const delegatedScope of delegatedScopes) {
285 if (!session.scopes.has(delegatedScope)) {
286 return yield* failEnvironmentScopeRequired(delegatedScope);
287 }
288 }
289 return yield* serverAuth.issuePairingCredential(args.payload);
290 },
291 Effect.catchTag("ServerAuthInternalError", (error) =>
292 failEnvironmentInternal("pairing_credential_issuance_failed", error),
293 ),
294 ),
295 )
296 .handle(
297 "pairingLinks",
298 Effect.fn("environment.auth.pairingLinks")(
299 function* (args) {
300 yield* annotateEnvironmentRequest(args.endpoint.name);
301 yield* requireEnvironmentScope(AuthAccessReadScope);
302 return yield* serverAuth.listPairingLinks();
303 },
304 Effect.catchTag("ServerAuthInternalError", (error) =>
305 failEnvironmentInternal("pairing_links_load_failed", error),
306 ),
307 ),
308 )
309 .handle(
310 "revokePairingLink",
311 Effect.fn("environment.auth.revokePairingLink")(
312 function* (args) {
313 yield* annotateEnvironmentRequest(args.endpoint.name);
314 yield* requireEnvironmentScope(AuthAccessWriteScope);
315 const revoked = yield* serverAuth.revokePairingLink(args.payload.id);
316 return { revoked };
317 },
318 Effect.catchTag("ServerAuthInternalError", (error) =>
319 failEnvironmentInternal("pairing_link_revoke_failed", error),
320 ),
321 ),
322 )
323 .handle(
324 "clients",
325 Effect.fn("environment.auth.clients")(
326 function* (args) {
327 yield* annotateEnvironmentRequest(args.endpoint.name);
328 const session = yield* requireEnvironmentScope(AuthAccessReadScope);
329 return yield* serverAuth.listClientSessions(session.sessionId);
330 },
331 Effect.catchTag("ServerAuthInternalError", (error) =>
332 failEnvironmentInternal("client_sessions_load_failed", error),
333 ),
334 ),
335 )
336 .handle(
337 "revokeClient",
338 Effect.fn("environment.auth.revokeClient")(
339 function* (args) {
340 yield* annotateEnvironmentRequest(args.endpoint.name);
341 const session = yield* requireEnvironmentScope(AuthAccessWriteScope);
342 const revoked = yield* serverAuth.revokeClientSession(
343 session.sessionId,
344 args.payload.sessionId,
345 );
346 return { revoked };
347 },
348 Effect.catchTags({
349 ServerAuthForbiddenOperationError: (error) =>
350 failEnvironmentOperationForbidden(error.reason),
351 ServerAuthInternalError: (error) =>
352 failEnvironmentInternal("client_session_revoke_failed", error),
353 }),
354 ),
355 )
356 .handle(
357 "revokeOtherClients",
358 Effect.fn("environment.auth.revokeOtherClients")(
359 function* (args) {
360 yield* annotateEnvironmentRequest(args.endpoint.name);
361 const session = yield* requireEnvironmentScope(AuthAccessWriteScope);
362 const revokedCount = yield* serverAuth.revokeOtherClientSessions(session.sessionId);
363 return { revokedCount };
364 },
365 Effect.catchTag("ServerAuthInternalError", (error) =>
366 failEnvironmentInternal("client_session_revoke_failed", error),
367 ),
368 ),
369 );
370 }),
265);371);
apps/server/src/auth/utils.test.ts+28
...@@ -23,4 +23,32 @@ describe("deriveAuthClientMetadata", () => {...@@ -23,4 +23,32 @@ describe("deriveAuthClientMetadata", () => {
23 os: "macOS",23 os: "macOS",
24 });24 });
25 });25 });
26
27 it("applies client-presented display identity without replacing transport metadata", () => {
28 const metadata = deriveAuthClientMetadata({
29 request: {
30 headers: {
31 "user-agent":
32 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/136.0.7103.93 Electron/36.3.2 Safari/537.36",
33 },
34 source: {
35 remoteAddress: "::ffff:192.168.213.72",
36 },
37 } as never,
38 presented: {
39 label: "T3 Code Mobile",
40 deviceType: "mobile",
41 os: "iOS",
42 },
43 });
44
45 expect(metadata).toMatchObject({
46 label: "T3 Code Mobile",
47 browser: "Electron",
48 deviceType: "mobile",
49 ipAddress: "192.168.213.72",
50 os: "iOS",
51 });
52 expect(metadata.userAgent).toContain("Electron/36.3.2");
53 });
26});54});
apps/server/src/auth/utils.ts+9-5
...@@ -1,4 +1,8 @@...@@ -1,4 +1,8 @@
1import type { AuthClientMetadata, AuthClientMetadataDeviceType } from "@t3tools/contracts";1import type {
2 AuthClientMetadata,
3 AuthClientMetadataDeviceType,
4 AuthClientPresentationMetadata,
5} from "@t3tools/contracts";
2import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";6import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
3import * as Crypto from "node:crypto";7import * as Crypto from "node:crypto";
48
...@@ -115,17 +119,17 @@ function readRemoteAddressFromSource(source: unknown): string | undefined {...@@ -115,17 +119,17 @@ function readRemoteAddressFromSource(source: unknown): string | undefined {
115119
116export function deriveAuthClientMetadata(input: {120export function deriveAuthClientMetadata(input: {
117 readonly request: HttpServerRequest.HttpServerRequest;121 readonly request: HttpServerRequest.HttpServerRequest;
118 readonly label?: string;122 readonly presented?: AuthClientPresentationMetadata;
119}): AuthClientMetadata {123}): AuthClientMetadata {
120 const userAgent = normalizeNonEmptyString(input.request.headers["user-agent"]);124 const userAgent = normalizeNonEmptyString(input.request.headers["user-agent"]);
121 const ipAddress = readRemoteAddressFromSource(input.request.source);125 const ipAddress = readRemoteAddressFromSource(input.request.source);
122 const os = inferOs(userAgent);126 const os = input.presented?.os ?? inferOs(userAgent);
123 const browser = inferBrowser(userAgent);127 const browser = inferBrowser(userAgent);
124 return {128 return {
125 ...(input.label ? { label: input.label } : {}),129 ...(input.presented?.label ? { label: input.presented.label } : {}),
126 ...(ipAddress ? { ipAddress } : {}),130 ...(ipAddress ? { ipAddress } : {}),
127 ...(userAgent ? { userAgent } : {}),131 ...(userAgent ? { userAgent } : {}),
128 deviceType: inferDeviceType(userAgent),132 deviceType: input.presented?.deviceType ?? inferDeviceType(userAgent),
129 ...(os ? { os } : {}),133 ...(os ? { os } : {}),
130 ...(browser ? { browser } : {}),134 ...(browser ? { browser } : {}),
131 };135 };
apps/server/src/bin.test.ts+35-15
...@@ -6,12 +6,15 @@ import { join } from "node:path";...@@ -6,12 +6,15 @@ import { join } from "node:path";
66
7import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";7import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
8import * as NodeServices from "@effect/platform-node/NodeServices";8import * as NodeServices from "@effect/platform-node/NodeServices";
9import { EnvironmentOrchestrationHttpApi } from "@t3tools/contracts";
9import * as NetService from "@t3tools/shared/Net";10import * as NetService from "@t3tools/shared/Net";
10import { assert, it } from "@effect/vitest";11import { assert, it } from "@effect/vitest";
11import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";13import * as Layer from "effect/Layer";
13import * as HttpRouter from "effect/unstable/http/HttpRouter";14import * as HttpRouter from "effect/unstable/http/HttpRouter";
14import * as HttpServer from "effect/unstable/http/HttpServer";15import * as HttpServer from "effect/unstable/http/HttpServer";
16import * as HttpApi from "effect/unstable/httpapi/HttpApi";
17import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
15import * as CliError from "effect/unstable/cli/CliError";18import * as CliError from "effect/unstable/cli/CliError";
16import * as TestConsole from "effect/testing/TestConsole";19import * as TestConsole from "effect/testing/TestConsole";
17import { Command } from "effect/unstable/cli";20import { Command } from "effect/unstable/cli";
...@@ -20,10 +23,7 @@ import { cli } from "./bin.ts";...@@ -20,10 +23,7 @@ import { cli } from "./bin.ts";
20import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts";23import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts";
21import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";24import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";
22import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";25import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
23import {26import { orchestrationHttpApiLayer } from "./orchestration/http.ts";
24 orchestrationDispatchRouteLayer,
25 orchestrationSnapshotRouteLayer,
26} from "./orchestration/http.ts";
27import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";27import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";
28import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts";28import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts";
29import {29import {
...@@ -31,10 +31,12 @@ import {...@@ -31,10 +31,12 @@ import {
31 persistServerRuntimeState,31 persistServerRuntimeState,
32} from "./serverRuntimeState.ts";32} from "./serverRuntimeState.ts";
33import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";33import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
34import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";34import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
35import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts";35import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
36import { environmentAuthenticatedAuthLayer } from "./auth/http.ts";
3637
37const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);38const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
39class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {}
3840
39const runCli = (args: ReadonlyArray<string>) => Command.runWith(cli, { version: "0.0.0" })(args);41const runCli = (args: ReadonlyArray<string>) => Command.runWith(cli, { version: "0.0.0" })(args);
40const runCliWithRuntime = (args: ReadonlyArray<string>) =>42const runCliWithRuntime = (args: ReadonlyArray<string>) =>
...@@ -105,18 +107,18 @@ const readPersistedSnapshot = (baseDir: string) =>...@@ -105,18 +107,18 @@ const readPersistedSnapshot = (baseDir: string) =>
105const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Effect<A, E, R>) =>107const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Effect<A, E, R>) =>
106 Effect.gen(function* () {108 Effect.gen(function* () {
107 const config = yield* makeCliTestServerConfig(baseDir);109 const config = yield* makeCliTestServerConfig(baseDir);
108 const routesLayer = Layer.mergeAll(110 const routesLayer = HttpApiBuilder.layer(ProjectCliHttpApi).pipe(
109 orchestrationSnapshotRouteLayer,111 Layer.provide(orchestrationHttpApiLayer),
110 orchestrationDispatchRouteLayer,112 Layer.provide(environmentAuthenticatedAuthLayer),
111 );113 );
112 const appLayer = HttpRouter.serve(routesLayer, {114 const appLayer = HttpRouter.serve(routesLayer, {
113 disableListenLog: true,115 disableListenLog: true,
114 disableLogger: true,116 disableLogger: true,
115 }).pipe(117 }).pipe(
116 Layer.provideMerge(118 Layer.provideMerge(
117 ServerAuthLive.pipe(119 EnvironmentAuth.layer.pipe(
118 Layer.provideMerge(SqlitePersistenceLayerLive),120 Layer.provideMerge(SqlitePersistenceLayerLive),
119 Layer.provide(ServerSecretStoreLive),121 Layer.provide(ServerSecretStore.layer),
120 ),122 ),
121 ),123 ),
122 Layer.provideMerge(makeProjectPersistenceLayer(config)),124 Layer.provideMerge(makeProjectPersistenceLayer(config)),
...@@ -214,7 +216,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {...@@ -214,7 +216,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
214 const issued = JSON.parse(issuedOutput.output) as {216 const issued = JSON.parse(issuedOutput.output) as {
215 readonly sessionId: string;217 readonly sessionId: string;
216 readonly token: string;218 readonly token: string;
217 readonly role: string;219 readonly scopes: ReadonlyArray<string>;
218 };220 };
219 const listedOutput = yield* captureStdout(221 const listedOutput = yield* captureStdout(
220 runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"]),222 runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"]),
...@@ -223,15 +225,33 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {...@@ -223,15 +225,33 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
223 const listed = JSON.parse(listedOutput.output) as ReadonlyArray<{225 const listed = JSON.parse(listedOutput.output) as ReadonlyArray<{
224 readonly sessionId: string;226 readonly sessionId: string;
225 readonly token?: string;227 readonly token?: string;
226 readonly role: string;228 readonly scopes: ReadonlyArray<string>;
227 }>;229 }>;
228230
229 assert.equal(typeof issued.sessionId, "string");231 assert.equal(typeof issued.sessionId, "string");
230 assert.equal(typeof issued.token, "string");232 assert.equal(typeof issued.token, "string");
231 assert.equal(issued.role, "owner");233 assert.deepEqual(issued.scopes, [
234 "orchestration:read",
235 "orchestration:operate",
236 "terminal:operate",
237 "review:write",
238 "relay:read",
239 "access:read",
240 "access:write",
241 "relay:write",
242 ]);
232 assert.equal(listed.length, 1);243 assert.equal(listed.length, 1);
233 assert.equal(listed[0]?.sessionId, issued.sessionId);244 assert.equal(listed[0]?.sessionId, issued.sessionId);
234 assert.equal(listed[0]?.role, "owner");245 assert.deepEqual(listed[0]?.scopes, [
246 "orchestration:read",
247 "orchestration:operate",
248 "terminal:operate",
249 "review:write",
250 "relay:read",
251 "access:read",
252 "access:write",
253 "relay:write",
254 ]);
235 assert.equal("token" in (listed[0] ?? {}), false);255 assert.equal("token" in (listed[0] ?? {}), false);
236 }),256 }),
237 );257 );
apps/server/src/cli/auth.ts+33-34
...@@ -1,4 +1,8 @@...@@ -1,4 +1,8 @@
1import { AuthSessionId } from "@t3tools/contracts";1import {
2 AuthAdministrativeScopes,
3 AuthSessionId,
4 AuthStandardClientScopes,
5} from "@t3tools/contracts";
2import * as Console from "effect/Console";6import * as Console from "effect/Console";
3import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
...@@ -6,9 +10,8 @@ import * as Option from "effect/Option";...@@ -6,9 +10,8 @@ import * as Option from "effect/Option";
6import * as References from "effect/References";10import * as References from "effect/References";
7import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli";11import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli";
812
9import { AuthControlPlaneRuntimeLive } from "../auth/Layers/AuthControlPlane.ts";13import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
10import { AuthControlPlane } from "../auth/Services/AuthControlPlane.ts";14
11import type { AuthControlPlaneShape } from "../auth/Services/AuthControlPlane.ts";
12import {15import {
13 formatIssuedPairingCredential,16 formatIssuedPairingCredential,
14 formatIssuedSession,17 formatIssuedSession,
...@@ -23,9 +26,9 @@ import {...@@ -23,9 +26,9 @@ import {
23 resolveCliAuthConfig,26 resolveCliAuthConfig,
24} from "./config.ts";27} from "./config.ts";
2528
26const runWithAuthControlPlane = <A, E>(29const runWithEnvironmentAuth = <A, E>(
27 flags: CliAuthLocationFlags,30 flags: CliAuthLocationFlags,
28 run: (authControlPlane: AuthControlPlaneShape) => Effect.Effect<A, E>,31 run: (environmentAuth: EnvironmentAuth.EnvironmentAuthShape) => Effect.Effect<A, E>,
29 options?: {32 options?: {
30 readonly quietLogs?: boolean;33 readonly quietLogs?: boolean;
31 },34 },
...@@ -35,11 +38,11 @@ const runWithAuthControlPlane = <A, E>(...@@ -35,11 +38,11 @@ const runWithAuthControlPlane = <A, E>(
35 const config = yield* resolveCliAuthConfig(flags, logLevel);38 const config = yield* resolveCliAuthConfig(flags, logLevel);
36 const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel;39 const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel;
37 return yield* Effect.gen(function* () {40 return yield* Effect.gen(function* () {
38 const authControlPlane = yield* AuthControlPlane;41 const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
39 return yield* run(authControlPlane);42 return yield* run(environmentAuth);
40 }).pipe(43 }).pipe(
41 Effect.provide(44 Effect.provide(
42 Layer.mergeAll(AuthControlPlaneRuntimeLive).pipe(45 Layer.mergeAll(EnvironmentAuth.runtimeLayer).pipe(
43 Layer.provide(Layer.succeed(ServerConfig, config)),46 Layer.provide(Layer.succeed(ServerConfig, config)),
44 Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),47 Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
45 ),48 ),
...@@ -58,11 +61,6 @@ const jsonFlag = Flag.boolean("json").pipe(...@@ -58,11 +61,6 @@ const jsonFlag = Flag.boolean("json").pipe(
58 Flag.withDefault(false),61 Flag.withDefault(false),
59);62);
6063
61const sessionRoleFlag = Flag.choice("role", ["owner", "client"]).pipe(
62 Flag.withDescription("Role for the issued bearer session."),
63 Flag.withDefault("owner"),
64);
65
66const labelFlag = Flag.string("label").pipe(64const labelFlag = Flag.string("label").pipe(
67 Flag.withDescription("Optional human-readable label."),65 Flag.withDescription("Optional human-readable label."),
68 Flag.optional,66 Flag.optional,
...@@ -92,12 +90,12 @@ const pairingCreateCommand = Command.make("create", {...@@ -92,12 +90,12 @@ const pairingCreateCommand = Command.make("create", {
92}).pipe(90}).pipe(
93 Command.withDescription("Issue a new client pairing token."),91 Command.withDescription("Issue a new client pairing token."),
94 Command.withHandler((flags) =>92 Command.withHandler((flags) =>
95 runWithAuthControlPlane(93 runWithEnvironmentAuth(
96 flags,94 flags,
97 (authControlPlane) =>95 (environmentAuth) =>
98 Effect.gen(function* () {96 Effect.gen(function* () {
99 const issued = yield* authControlPlane.createPairingLink({97 const issued = yield* environmentAuth.createPairingLink({
100 role: "client",98 scopes: AuthStandardClientScopes,
101 subject: "one-time-token",99 subject: "one-time-token",
102 ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}),100 ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}),
103 ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}),101 ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}),
...@@ -121,11 +119,13 @@ const pairingListCommand = Command.make("list", {...@@ -121,11 +119,13 @@ const pairingListCommand = Command.make("list", {
121}).pipe(119}).pipe(
122 Command.withDescription("List active client pairing tokens without revealing their secrets."),120 Command.withDescription("List active client pairing tokens without revealing their secrets."),
123 Command.withHandler((flags) =>121 Command.withHandler((flags) =>
124 runWithAuthControlPlane(122 runWithEnvironmentAuth(
125 flags,123 flags,
126 (authControlPlane) =>124 (environmentAuth) =>
127 Effect.gen(function* () {125 Effect.gen(function* () {
128 const pairingLinks = yield* authControlPlane.listPairingLinks({ role: "client" });126 const pairingLinks = yield* environmentAuth.listPairingLinks({
127 excludeSubjects: [EnvironmentAuth.INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT],
128 });
129 yield* Console.log(formatPairingCredentialList(pairingLinks, { json: flags.json }));129 yield* Console.log(formatPairingCredentialList(pairingLinks, { json: flags.json }));
130 }),130 }),
131 {131 {
...@@ -141,9 +141,9 @@ const pairingRevokeCommand = Command.make("revoke", {...@@ -141,9 +141,9 @@ const pairingRevokeCommand = Command.make("revoke", {
141}).pipe(141}).pipe(
142 Command.withDescription("Revoke an active client pairing token."),142 Command.withDescription("Revoke an active client pairing token."),
143 Command.withHandler((flags) =>143 Command.withHandler((flags) =>
144 runWithAuthControlPlane(flags, (authControlPlane) =>144 runWithEnvironmentAuth(flags, (environmentAuth) =>
145 Effect.gen(function* () {145 Effect.gen(function* () {
146 const revoked = yield* authControlPlane.revokePairingLink(flags.id);146 const revoked = yield* environmentAuth.revokePairingLink(flags.id);
147 yield* Console.log(147 yield* Console.log(
148 revoked148 revoked
149 ? `Revoked pairing credential ${flags.id}.\n`149 ? `Revoked pairing credential ${flags.id}.\n`
...@@ -162,20 +162,19 @@ const pairingCommand = Command.make("pairing").pipe(...@@ -162,20 +162,19 @@ const pairingCommand = Command.make("pairing").pipe(
162const sessionIssueCommand = Command.make("issue", {162const sessionIssueCommand = Command.make("issue", {
163 ...authLocationFlags,163 ...authLocationFlags,
164 ttl: ttlFlag,164 ttl: ttlFlag,
165 role: sessionRoleFlag,
166 label: labelFlag,165 label: labelFlag,
167 subject: subjectFlag,166 subject: subjectFlag,
168 tokenOnly: tokenOnlyFlag,167 tokenOnly: tokenOnlyFlag,
169 json: jsonFlag,168 json: jsonFlag,
170}).pipe(169}).pipe(
171 Command.withDescription("Issue a bearer session token for headless or remote clients."),170 Command.withDescription("Issue a scoped bearer access token for headless or remote clients."),
172 Command.withHandler((flags) =>171 Command.withHandler((flags) =>
173 runWithAuthControlPlane(172 runWithEnvironmentAuth(
174 flags,173 flags,
175 (authControlPlane) =>174 (environmentAuth) =>
176 Effect.gen(function* () {175 Effect.gen(function* () {
177 const issued = yield* authControlPlane.issueSession({176 const issued = yield* environmentAuth.issueSession({
178 role: flags.role,177 scopes: AuthAdministrativeScopes,
179 ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}),178 ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}),
180 ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}),179 ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}),
181 ...(Option.isSome(flags.subject) ? { subject: flags.subject.value } : {}),180 ...(Option.isSome(flags.subject) ? { subject: flags.subject.value } : {}),
...@@ -200,11 +199,11 @@ const sessionListCommand = Command.make("list", {...@@ -200,11 +199,11 @@ const sessionListCommand = Command.make("list", {
200}).pipe(199}).pipe(
201 Command.withDescription("List active sessions without revealing bearer tokens."),200 Command.withDescription("List active sessions without revealing bearer tokens."),
202 Command.withHandler((flags) =>201 Command.withHandler((flags) =>
203 runWithAuthControlPlane(202 runWithEnvironmentAuth(
204 flags,203 flags,
205 (authControlPlane) =>204 (environmentAuth) =>
206 Effect.gen(function* () {205 Effect.gen(function* () {
207 const sessions = yield* authControlPlane.listSessions();206 const sessions = yield* environmentAuth.listSessions();
208 yield* Console.log(formatSessionList(sessions, { json: flags.json }));207 yield* Console.log(formatSessionList(sessions, { json: flags.json }));
209 }),208 }),
210 {209 {
...@@ -223,9 +222,9 @@ const sessionRevokeCommand = Command.make("revoke", {...@@ -223,9 +222,9 @@ const sessionRevokeCommand = Command.make("revoke", {
223}).pipe(222}).pipe(
224 Command.withDescription("Revoke an active session."),223 Command.withDescription("Revoke an active session."),
225 Command.withHandler((flags) =>224 Command.withHandler((flags) =>
226 runWithAuthControlPlane(flags, (authControlPlane) =>225 runWithEnvironmentAuth(flags, (environmentAuth) =>
227 Effect.gen(function* () {226 Effect.gen(function* () {
228 const revoked = yield* authControlPlane.revokeSession(flags.sessionId);227 const revoked = yield* environmentAuth.revokeSession(flags.sessionId);
229 yield* Console.log(228 yield* Console.log(
230 revoked229 revoked
231 ? `Revoked session ${flags.sessionId}.\n`230 ? `Revoked session ${flags.sessionId}.\n`
apps/server/src/cli/project.ts+61-77
...@@ -1,6 +1,9 @@...@@ -1,6 +1,9 @@
1import {1import {
2 CommandId,2 CommandId,
3 OrchestrationReadModel,3 AuthAdministrativeScopes,
4 EnvironmentHttpApi,
5 EnvironmentHttpCommonError,
6 type OrchestrationReadModel,
4 ProjectId,7 ProjectId,
5 type ClientOrchestrationCommand,8 type ClientOrchestrationCommand,
6} from "@t3tools/contracts";9} from "@t3tools/contracts";
...@@ -18,16 +21,11 @@ import * as Path from "effect/Path";...@@ -18,16 +21,11 @@ import * as Path from "effect/Path";
18import * as References from "effect/References";21import * as References from "effect/References";
19import * as Schema from "effect/Schema";22import * as Schema from "effect/Schema";
20import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli";23import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli";
21import {24import { FetchHttpClient, HttpClient, HttpClientError } from "effect/unstable/http";
22 FetchHttpClient,25import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
23 HttpClient,26
24 HttpClientRequest,27import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
25 HttpClientResponse,28
26} from "effect/unstable/http";
27
28import { AuthControlPlaneRuntimeLive } from "../auth/Layers/AuthControlPlane.ts";
29import { AuthControlPlane } from "../auth/Services/AuthControlPlane.ts";
30import type { AuthControlPlaneShape } from "../auth/Services/AuthControlPlane.ts";
31import { ServerConfig, type ServerConfigShape } from "../config.ts";29import { ServerConfig, type ServerConfigShape } from "../config.ts";
32import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts";30import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts";
33import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts";31import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts";
...@@ -78,50 +76,53 @@ const ProjectCliRuntimeLive = Layer.mergeAll(...@@ -78,50 +76,53 @@ const ProjectCliRuntimeLive = Layer.mergeAll(
78);76);
7977
80const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1);78const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1);
81const OrchestrationHttpErrorResponse = Schema.Struct({79const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError);
82 error: Schema.String,
83});
84
85const withProjectCliSessionToken = <A, E, R>(80const withProjectCliSessionToken = <A, E, R>(
86 authControlPlane: AuthControlPlaneShape,81 environmentAuth: EnvironmentAuth.EnvironmentAuthShape,
87 run: (token: string) => Effect.Effect<A, E, R>,82 run: (token: string) => Effect.Effect<A, E, R>,
88) =>83) =>
89 Effect.acquireUseRelease(84 Effect.acquireUseRelease(
90 authControlPlane.issueSession({85 environmentAuth.issueSession({
91 role: "owner",86 scopes: AuthAdministrativeScopes,
92 label: "t3 project cli",87 label: "t3 project cli",
93 }),88 }),
94 (issued) => run(issued.token),89 (issued) => run(issued.token),
95 (issued) => authControlPlane.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })),90 (issued) => environmentAuth.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })),
96 );91 );
9792
98const withProjectCliLiveServerTimeout = <A, E, R>(effect: Effect.Effect<A, E, R>) =>93const withProjectCliLiveServerTimeout = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
99 effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT));94 effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT));
10095
101const runLiveServerRequest = <A, E extends Error, R>(96const failLiveServerRequest = (cause: unknown) => {
102 request: HttpClientRequest.HttpClientRequest,97 if (isEnvironmentHttpCommonError(cause)) {
103 handle: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect<A, E, R>,98 return Effect.fail(
104) =>99 new ProjectCommandError({
105 Effect.gen(function* () {100 message: `Server request failed (${cause.code}, trace ${cause.traceId}).`,
106 const httpClient = yield* HttpClient.HttpClient;101 }),
107 const response = yield* httpClient.execute(request);102 );
108 return yield* handle(response);103 }
109 }).pipe(withProjectCliLiveServerTimeout);104 if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) {
110105 return Effect.fail(
111const decodeOrchestrationReadModelResponse = (response: HttpClientResponse.HttpClientResponse) =>106 new ProjectCommandError({
112 HttpClientResponse.schemaBodyJson(OrchestrationReadModel)(response);107 message: `Server request failed with undeclared status ${cause.response.status}.`,
113108 }),
114const readErrorMessageFromResponse = (response: HttpClientResponse.HttpClientResponse) =>109 );
115 HttpClientResponse.schemaBodyJson(OrchestrationHttpErrorResponse)(response).pipe(110 }
116 Effect.map((body) => body.error),111 return Effect.fail(
117 Effect.catch(() => Effect.succeed(null)),112 new ProjectCommandError({
118 Effect.map((body) => {113 message: `Failed to call running server: ${String(cause)}.`,
119 if (typeof body === "string" && body.trim().length > 0) {
120 return body;
121 }
122 return `Server request failed with status ${response.status}.`;
123 }),114 }),
124 );115 );
116};
117
118const makeLiveServerClient = (origin: string) =>
119 Effect.gen(function* () {
120 const httpClient = yield* HttpClient.HttpClient;
121 return yield* HttpApiClient.makeWith(EnvironmentHttpApi, {
122 baseUrl: origin,
123 httpClient,
124 });
125 });
125126
126const normalizeWorkspaceRootForProjectCommand = Effect.fn(127const normalizeWorkspaceRootForProjectCommand = Effect.fn(
127 "normalizeWorkspaceRootForProjectCommand",128 "normalizeWorkspaceRootForProjectCommand",
...@@ -193,42 +194,25 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (...@@ -193,42 +194,25 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (
193});194});
194195
195const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) =>196const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) =>
196 runLiveServerRequest(197 Effect.gen(function* () {
197 HttpClientRequest.get(`${origin}/api/orchestration/snapshot`).pipe(198 const client = yield* makeLiveServerClient(origin);
198 HttpClientRequest.acceptJson,199 return yield* client.orchestration.snapshot({
199 HttpClientRequest.bearerToken(bearerToken),200 headers: { authorization: `Bearer ${bearerToken}` },
200 ),201 });
201 HttpClientResponse.matchStatus({202 }).pipe(withProjectCliLiveServerTimeout, Effect.catch(failLiveServerRequest));
202 "2xx": decodeOrchestrationReadModelResponse,
203 orElse: (response) =>
204 readErrorMessageFromResponse(response).pipe(
205 Effect.flatMap((message) => Effect.fail(new ProjectCommandError({ message }))),
206 ),
207 }),
208 );
209203
210const dispatchLiveOrchestrationCommand = (204const dispatchLiveOrchestrationCommand = (
211 origin: string,205 origin: string,
212 bearerToken: string,206 bearerToken: string,
213 command: ProjectCliDispatchCommand,207 command: ProjectCliDispatchCommand,
214) =>208) =>
215 HttpClientRequest.post(`${origin}/api/orchestration/dispatch`).pipe(209 Effect.gen(function* () {
216 HttpClientRequest.acceptJson,210 const client = yield* makeLiveServerClient(origin);
217 HttpClientRequest.bearerToken(bearerToken),211 yield* client.orchestration.dispatch({
218 HttpClientRequest.bodyJson(command),212 headers: { authorization: `Bearer ${bearerToken}` },
219 Effect.flatMap((request) =>213 payload: command,
220 runLiveServerRequest(214 } as Parameters<typeof client.orchestration.dispatch>[0]);
221 request,215 }).pipe(withProjectCliLiveServerTimeout, Effect.catch(failLiveServerRequest));
222 HttpClientResponse.matchStatus({
223 "2xx": () => Effect.void,
224 orElse: (response) =>
225 readErrorMessageFromResponse(response).pipe(
226 Effect.flatMap((message) => Effect.fail(new ProjectCommandError({ message }))),
227 ),
228 }),
229 ),
230 ),
231 );
232216
233const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () {217const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () {
234 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;218 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
...@@ -236,13 +220,13 @@ const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () {...@@ -236,13 +220,13 @@ const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () {
236});220});
237221
238const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecutionMode")(222const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecutionMode")(
239 function* (authControlPlane: AuthControlPlaneShape, config: ServerConfigShape) {223 function* (environmentAuth: EnvironmentAuth.EnvironmentAuthShape, config: ServerConfigShape) {
240 const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath);224 const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath);
241 if (Option.isNone(runtimeState)) {225 if (Option.isNone(runtimeState)) {
242 return Option.none<{ readonly origin: string }>();226 return Option.none<{ readonly origin: string }>();
243 }227 }
244228
245 const attempt = withProjectCliSessionToken(authControlPlane, (token) =>229 const attempt = withProjectCliSessionToken(environmentAuth, (token) =>
246 fetchLiveOrchestrationSnapshot(runtimeState.value.origin, token).pipe(230 fetchLiveOrchestrationSnapshot(runtimeState.value.origin, token).pipe(
247 Effect.as({231 Effect.as({
248 origin: runtimeState.value.origin,232 origin: runtimeState.value.origin,
...@@ -279,11 +263,11 @@ const runProjectMutation = Effect.fn("runProjectMutation")(function* (...@@ -279,11 +263,11 @@ const runProjectMutation = Effect.fn("runProjectMutation")(function* (
279 const minimumLogLevel = config.logLevel;263 const minimumLogLevel = config.logLevel;
280264
281 return yield* Effect.gen(function* () {265 return yield* Effect.gen(function* () {
282 const authControlPlane = yield* AuthControlPlane;266 const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
283 const liveMode = yield* tryResolveLiveProjectExecutionMode(authControlPlane, config);267 const liveMode = yield* tryResolveLiveProjectExecutionMode(environmentAuth, config);
284268
285 if (Option.isSome(liveMode)) {269 if (Option.isSome(liveMode)) {
286 return yield* withProjectCliSessionToken(authControlPlane, (token) =>270 return yield* withProjectCliSessionToken(environmentAuth, (token) =>
287 Effect.gen(function* () {271 Effect.gen(function* () {
288 const snapshot = yield* fetchLiveOrchestrationSnapshot(liveMode.value.origin, token);272 const snapshot = yield* fetchLiveOrchestrationSnapshot(liveMode.value.origin, token);
289 const output = yield* run({273 const output = yield* run({
...@@ -314,7 +298,7 @@ const runProjectMutation = Effect.fn("runProjectMutation")(function* (...@@ -314,7 +298,7 @@ const runProjectMutation = Effect.fn("runProjectMutation")(function* (
314 }).pipe(Effect.provide(offlineRuntimeLayer));298 }).pipe(Effect.provide(offlineRuntimeLayer));
315 }).pipe(299 }).pipe(
316 Effect.provide(300 Effect.provide(
317 Layer.mergeAll(AuthControlPlaneRuntimeLive, WorkspacePathsLive).pipe(301 Layer.mergeAll(EnvironmentAuth.runtimeLayer, WorkspacePathsLive).pipe(
318 Layer.provideMerge(FetchHttpClient.layer),302 Layer.provideMerge(FetchHttpClient.layer),
319 Layer.provide(Layer.succeed(ServerConfig, config)),303 Layer.provide(Layer.succeed(ServerConfig, config)),
320 Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),304 Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
apps/server/src/cliAuthFormat.test.ts+6-6
...@@ -13,7 +13,7 @@ it("formats issued pairing credentials with the secret and optional pair URL", (...@@ -13,7 +13,7 @@ it("formats issued pairing credentials with the secret and optional pair URL", (
13 {13 {
14 id: "pairing-1",14 id: "pairing-1",
15 credential: "secret-pairing-token",15 credential: "secret-pairing-token",
16 role: "client",16 scopes: ["orchestration:read"],
17 subject: "one-time-token",17 subject: "one-time-token",
18 createdAt: DateTime.makeUnsafe("2026-04-08T09:00:00.000Z"),18 createdAt: DateTime.makeUnsafe("2026-04-08T09:00:00.000Z"),
19 expiresAt: DateTime.makeUnsafe("2026-04-08T10:00:00.000Z"),19 expiresAt: DateTime.makeUnsafe("2026-04-08T10:00:00.000Z"),
...@@ -33,7 +33,7 @@ it("formats pairing listings without exposing the secret token", () => {...@@ -33,7 +33,7 @@ it("formats pairing listings without exposing the secret token", () => {
33 credential: "secret-pairing-token",33 credential: "secret-pairing-token",
34 subject: "one-time-token",34 subject: "one-time-token",
35 label: "Phone",35 label: "Phone",
36 role: "client",36 scopes: ["orchestration:read"],
37 createdAt: DateTime.makeUnsafe("2026-04-08T09:00:00.000Z"),37 createdAt: DateTime.makeUnsafe("2026-04-08T09:00:00.000Z"),
38 expiresAt: DateTime.makeUnsafe("2026-04-08T10:00:00.000Z"),38 expiresAt: DateTime.makeUnsafe("2026-04-08T10:00:00.000Z"),
39 },39 },
...@@ -50,8 +50,8 @@ it("formats issued sessions with the bearer token but omits tokens from listings...@@ -50,8 +50,8 @@ it("formats issued sessions with the bearer token but omits tokens from listings
50 {50 {
51 sessionId: "session-1" as never,51 sessionId: "session-1" as never,
52 token: "secret-session-token",52 token: "secret-session-token",
53 method: "bearer-session-token",53 method: "bearer-access-token",
54 role: "owner",54 scopes: ["orchestration:read", "access:write"],
55 subject: "cli-issued-session",55 subject: "cli-issued-session",
56 client: {56 client: {
57 label: "deploy-bot",57 label: "deploy-bot",
...@@ -66,8 +66,8 @@ it("formats issued sessions with the bearer token but omits tokens from listings...@@ -66,8 +66,8 @@ it("formats issued sessions with the bearer token but omits tokens from listings
66 [66 [
67 {67 {
68 sessionId: "session-1" as never,68 sessionId: "session-1" as never,
69 method: "bearer-session-token",69 method: "bearer-access-token",
70 role: "owner",70 scopes: ["orchestration:read", "access:write"],
71 subject: "cli-issued-session",71 subject: "cli-issued-session",
72 client: {72 client: {
73 label: "deploy-bot",73 label: "deploy-bot",
apps/server/src/cliAuthFormat.ts+10-8
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import type { AuthClientMetadata, AuthClientSession, AuthPairingLink } from "@t3tools/contracts";1import type { AuthClientMetadata, AuthClientSession, AuthPairingLink } from "@t3tools/contracts";
2import * as DateTime from "effect/DateTime";2import * as DateTime from "effect/DateTime";
33
4import type { IssuedBearerSession, IssuedPairingLink } from "./auth/Services/AuthControlPlane.ts";4import type { IssuedBearerSession, IssuedPairingLink } from "./auth/EnvironmentAuth.ts";
55
6const newline = "\n";6const newline = "\n";
77
...@@ -47,7 +47,7 @@ export function formatIssuedPairingCredential(...@@ -47,7 +47,7 @@ export function formatIssuedPairingCredential(
47 id: credential.id,47 id: credential.id,
48 credential: credential.credential,48 credential: credential.credential,
49 ...(credential.label ? { label: credential.label } : {}),49 ...(credential.label ? { label: credential.label } : {}),
50 role: credential.role,50 scopes: credential.scopes,
51 expiresAt: toIsoString(credential.expiresAt),51 expiresAt: toIsoString(credential.expiresAt),
52 ...(pairUrl ? { pairUrl } : {}),52 ...(pairUrl ? { pairUrl } : {}),
53 },53 },
...@@ -77,7 +77,7 @@ export function formatPairingCredentialList(...@@ -77,7 +77,7 @@ export function formatPairingCredentialList(
77 credentials.map((credential) => ({77 credentials.map((credential) => ({
78 id: credential.id,78 id: credential.id,
79 ...(credential.label ? { label: credential.label } : {}),79 ...(credential.label ? { label: credential.label } : {}),
80 role: credential.role,80 scopes: credential.scopes,
81 createdAt: toIsoString(credential.createdAt),81 createdAt: toIsoString(credential.createdAt),
82 expiresAt: toIsoString(credential.expiresAt),82 expiresAt: toIsoString(credential.expiresAt),
83 })),83 })),
...@@ -95,7 +95,7 @@ export function formatPairingCredentialList(...@@ -95,7 +95,7 @@ export function formatPairingCredentialList(
95 .map((credential) =>95 .map((credential) =>
96 [96 [
97 `${credential.id}${credential.label ? ` (${credential.label})` : ""}`,97 `${credential.id}${credential.label ? ` (${credential.label})` : ""}`,
98 ` role: ${credential.role}`,98 ` scopes: ${credential.scopes.join(" ")}`,
99 ` created: ${toIsoString(credential.createdAt)}`,99 ` created: ${toIsoString(credential.createdAt)}`,
100 ` expires: ${toIsoString(credential.expiresAt)}`,100 ` expires: ${toIsoString(credential.expiresAt)}`,
101 ].join(newline),101 ].join(newline),
...@@ -121,7 +121,7 @@ export function formatIssuedSession(...@@ -121,7 +121,7 @@ export function formatIssuedSession(
121 sessionId: session.sessionId,121 sessionId: session.sessionId,
122 token: session.token,122 token: session.token,
123 method: session.method,123 method: session.method,
124 role: session.role,124 scopes: session.scopes,
125 subject: session.subject,125 subject: session.subject,
126 client: session.client,126 client: session.client,
127 expiresAt: toIsoString(session.expiresAt),127 expiresAt: toIsoString(session.expiresAt),
...@@ -133,7 +133,8 @@ export function formatIssuedSession(...@@ -133,7 +133,8 @@ export function formatIssuedSession(
133133
134 return (134 return (
135 [135 [
136 `Issued ${session.role} bearer session ${session.sessionId}.`,136 `Issued bearer access token ${session.sessionId}.`,
137 `Scopes: ${session.scopes.join(" ")}`,
137 `Token: ${session.token}`,138 `Token: ${session.token}`,
138 `Subject: ${session.subject}`,139 `Subject: ${session.subject}`,
139 `Client: ${formatClientMetadata(session.client)}`,140 `Client: ${formatClientMetadata(session.client)}`,
...@@ -153,7 +154,7 @@ export function formatSessionList(...@@ -153,7 +154,7 @@ export function formatSessionList(
153 sessions.map((session) => ({154 sessions.map((session) => ({
154 sessionId: session.sessionId,155 sessionId: session.sessionId,
155 method: session.method,156 method: session.method,
156 role: session.role,157 scopes: session.scopes,
157 subject: session.subject,158 subject: session.subject,
158 client: session.client,159 client: session.client,
159 connected: session.connected,160 connected: session.connected,
...@@ -174,7 +175,8 @@ export function formatSessionList(...@@ -174,7 +175,8 @@ export function formatSessionList(
174 sessions175 sessions
175 .map((session) =>176 .map((session) =>
176 [177 [
177 `${session.sessionId} [${session.role}]${session.connected ? " connected" : ""}`,178 `${session.sessionId}${session.connected ? " connected" : ""}`,
179 ` scopes: ${session.scopes.join(" ")}`,
178 ` method: ${session.method}`,180 ` method: ${session.method}`,
179 ` subject: ${session.subject}`,181 ` subject: ${session.subject}`,
180 ` client: ${formatClientMetadata(session.client)}`,182 ` client: ${formatClientMetadata(session.client)}`,
apps/server/src/http.ts+68-30
...@@ -1,4 +1,9 @@...@@ -1,4 +1,9 @@
1import Mime from "@effect/platform-node/Mime";1import Mime from "@effect/platform-node/Mime";
2import {
3 AuthOrchestrationOperateScope,
4 AuthOrchestrationReadScope,
5 EnvironmentHttpApi,
6} from "@t3tools/contracts";
2import { decodeOtlpTraceRecords } from "@t3tools/shared/observability";7import { decodeOtlpTraceRecords } from "@t3tools/shared/observability";
3import * as Data from "effect/Data";8import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
...@@ -13,7 +18,9 @@ import {...@@ -13,7 +18,9 @@ import {
13 HttpRouter,18 HttpRouter,
14 HttpServerResponse,19 HttpServerResponse,
15 HttpServerRequest,20 HttpServerRequest,
21 HttpServerRespondable,
16} from "effect/unstable/http";22} from "effect/unstable/http";
23import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
17import { OtlpTracer } from "effect/unstable/observability";24import { OtlpTracer } from "effect/unstable/observability";
1825
19import {26import {
...@@ -25,14 +32,15 @@ import { resolveAttachmentPathById } from "./attachmentStore.ts";...@@ -25,14 +32,15 @@ import { resolveAttachmentPathById } from "./attachmentStore.ts";
25import { resolveStaticDir, ServerConfig } from "./config.ts";32import { resolveStaticDir, ServerConfig } from "./config.ts";
26import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts";33import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts";
27import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts";34import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts";
28import { ServerAuth } from "./auth/Services/ServerAuth.ts";35import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
29import { respondToAuthError } from "./auth/http.ts";
30import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";
31import {36import {
32 browserApiCorsAllowedHeaders,37 annotateEnvironmentRequest,
33 browserApiCorsAllowedMethods,38 failEnvironmentScopeRequired,
34 browserApiCorsHeaders,39 failEnvironmentAuthInvalid,
35} from "./httpCors.ts";40 failEnvironmentInternal,
41} from "./auth/http.ts";
42import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";
43import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts";
3644
37const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600";45const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600";
38const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`;46const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`;
...@@ -40,8 +48,8 @@ const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";...@@ -40,8 +48,8 @@ const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
40const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);48const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
4149
42export const browserApiCorsLayer = HttpRouter.cors({50export const browserApiCorsLayer = HttpRouter.cors({
43 allowedMethods: [...browserApiCorsAllowedMethods],51 allowedMethods: browserApiCorsAllowedMethods,
44 allowedHeaders: [...browserApiCorsAllowedHeaders],52 allowedHeaders: browserApiCorsAllowedHeaders,
45 maxAge: 600,53 maxAge: 600,
46});54});
4755
...@@ -61,23 +69,35 @@ export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string {...@@ -61,23 +69,35 @@ export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string {
61 return redirectUrl.toString();69 return redirectUrl.toString();
62}70}
6371
64const requireAuthenticatedRequest = Effect.gen(function* () {72const authenticateRawRouteWithScope = (
65 const request = yield* HttpServerRequest.HttpServerRequest;73 scope: typeof AuthOrchestrationReadScope | typeof AuthOrchestrationOperateScope,
66 const serverAuth = yield* ServerAuth;74) =>
67 yield* serverAuth.authenticateHttpRequest(request);
68});
69
70export const serverEnvironmentRouteLayer = HttpRouter.add(
71 "GET",
72 "/.well-known/t3/environment",
73 Effect.gen(function* () {75 Effect.gen(function* () {
74 const descriptor = yield* Effect.service(ServerEnvironment).pipe(76 const request = yield* HttpServerRequest.HttpServerRequest;
75 Effect.flatMap((serverEnvironment) => serverEnvironment.getDescriptor),77 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
78 const session = yield* serverAuth.authenticateHttpRequest(request).pipe(
79 Effect.catchTags({
80 ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason),
81 ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error),
82 }),
83 );
84 if (!session.scopes.includes(scope)) {
85 return yield* failEnvironmentScopeRequired(scope);
86 }
87 });
88
89export const serverEnvironmentHttpApiLayer = HttpApiBuilder.group(
90 EnvironmentHttpApi,
91 "metadata",
92 Effect.fnUntraced(function* (handlers) {
93 const serverEnvironment = yield* ServerEnvironment;
94 return handlers.handle(
95 "descriptor",
96 Effect.fn("environment.metadata.descriptor")(function* (args) {
97 yield* annotateEnvironmentRequest(args.endpoint.name);
98 return yield* serverEnvironment.getDescriptor;
99 }),
76 );100 );
77 return HttpServerResponse.jsonUnsafe(descriptor, {
78 status: 200,
79 headers: browserApiCorsHeaders,
80 });
81 }),101 }),
82);102);
83103
...@@ -90,7 +110,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(...@@ -90,7 +110,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(
90 "POST",110 "POST",
91 OTLP_TRACES_PROXY_PATH,111 OTLP_TRACES_PROXY_PATH,
92 Effect.gen(function* () {112 Effect.gen(function* () {
93 yield* requireAuthenticatedRequest;113 yield* authenticateRawRouteWithScope(AuthOrchestrationOperateScope);
94 const request = yield* HttpServerRequest.HttpServerRequest;114 const request = yield* HttpServerRequest.HttpServerRequest;
95 const config = yield* ServerConfig;115 const config = yield* ServerConfig;
96 const otlpTracesUrl = config.otlpTracesUrl;116 const otlpTracesUrl = config.otlpTracesUrl;
...@@ -132,14 +152,20 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(...@@ -132,14 +152,20 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(
132 Effect.succeed(HttpServerResponse.text("Trace export failed.", { status: 502 })),152 Effect.succeed(HttpServerResponse.text("Trace export failed.", { status: 502 })),
133 ),153 ),
134 );154 );
135 }).pipe(Effect.catchTag("AuthError", respondToAuthError)),155 }).pipe(
156 Effect.catchTags({
157 EnvironmentAuthInvalidError: HttpServerRespondable.toResponse,
158 EnvironmentInternalError: HttpServerRespondable.toResponse,
159 EnvironmentScopeRequiredError: HttpServerRespondable.toResponse,
160 }),
161 ),
136);162);
137163
138export const attachmentsRouteLayer = HttpRouter.add(164export const attachmentsRouteLayer = HttpRouter.add(
139 "GET",165 "GET",
140 `${ATTACHMENTS_ROUTE_PREFIX}/*`,166 `${ATTACHMENTS_ROUTE_PREFIX}/*`,
141 Effect.gen(function* () {167 Effect.gen(function* () {
142 yield* requireAuthenticatedRequest;168 yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope);
143 const request = yield* HttpServerRequest.HttpServerRequest;169 const request = yield* HttpServerRequest.HttpServerRequest;
144 const url = HttpServerRequest.toURL(request);170 const url = HttpServerRequest.toURL(request);
145 if (Option.isNone(url)) {171 if (Option.isNone(url)) {
...@@ -188,14 +214,20 @@ export const attachmentsRouteLayer = HttpRouter.add(...@@ -188,14 +214,20 @@ export const attachmentsRouteLayer = HttpRouter.add(
188 Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })),214 Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })),
189 ),215 ),
190 );216 );
191 }).pipe(Effect.catchTag("AuthError", respondToAuthError)),217 }).pipe(
218 Effect.catchTags({
219 EnvironmentAuthInvalidError: HttpServerRespondable.toResponse,
220 EnvironmentInternalError: HttpServerRespondable.toResponse,
221 EnvironmentScopeRequiredError: HttpServerRespondable.toResponse,
222 }),
223 ),
192);224);
193225
194export const projectFaviconRouteLayer = HttpRouter.add(226export const projectFaviconRouteLayer = HttpRouter.add(
195 "GET",227 "GET",
196 "/api/project-favicon",228 "/api/project-favicon",
197 Effect.gen(function* () {229 Effect.gen(function* () {
198 yield* requireAuthenticatedRequest;230 yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope);
199 const request = yield* HttpServerRequest.HttpServerRequest;231 const request = yield* HttpServerRequest.HttpServerRequest;
200 const url = HttpServerRequest.toURL(request);232 const url = HttpServerRequest.toURL(request);
201 if (Option.isNone(url)) {233 if (Option.isNone(url)) {
...@@ -229,7 +261,13 @@ export const projectFaviconRouteLayer = HttpRouter.add(...@@ -229,7 +261,13 @@ export const projectFaviconRouteLayer = HttpRouter.add(
229 Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })),261 Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })),
230 ),262 ),
231 );263 );
232 }).pipe(Effect.catchTag("AuthError", respondToAuthError)),264 }).pipe(
265 Effect.catchTags({
266 EnvironmentAuthInvalidError: HttpServerRespondable.toResponse,
267 EnvironmentInternalError: HttpServerRespondable.toResponse,
268 EnvironmentScopeRequiredError: HttpServerRespondable.toResponse,
269 }),
270 ),
233);271);
234272
235export const staticAndDevRouteLayer = HttpRouter.add(273export const staticAndDevRouteLayer = HttpRouter.add(
apps/server/src/orchestration/http.ts+48-84
...@@ -1,95 +1,59 @@...@@ -1,95 +1,59 @@
1import {1import {
2 ClientOrchestrationCommand,2 AuthOrchestrationOperateScope,
3 OrchestrationDispatchCommandError,3 AuthOrchestrationReadScope,
4 OrchestrationGetSnapshotError,4 EnvironmentHttpApi,
5 type OrchestrationReadModel,
6} from "@t3tools/contracts";5} from "@t3tools/contracts";
7import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
8import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";7import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
98
10import { ServerAuth } from "../auth/Services/ServerAuth.ts";
11import { normalizeDispatchCommand } from "./Normalizer.ts";9import { normalizeDispatchCommand } from "./Normalizer.ts";
10import {
11 annotateEnvironmentRequest,
12 failEnvironmentInternal,
13 failEnvironmentInvalidRequest,
14 requireEnvironmentScope,
15} from "../auth/http.ts";
12import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";16import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";
13import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";17import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
1418
15const respondToOrchestrationHttpError = (19export const orchestrationHttpApiLayer = HttpApiBuilder.group(
16 error: OrchestrationDispatchCommandError | OrchestrationGetSnapshotError,20 EnvironmentHttpApi,
17) =>21 "orchestration",
18 Effect.gen(function* () {22 Effect.fnUntraced(function* (handlers) {
19 if (error._tag === "OrchestrationGetSnapshotError") {
20 yield* Effect.logError("orchestration http route failed", {
21 message: error.message,
22 cause: error.cause,
23 });
24 return HttpServerResponse.jsonUnsafe({ error: error.message }, { status: 500 });
25 }
26
27 return HttpServerResponse.jsonUnsafe({ error: error.message }, { status: 400 });
28 });
29
30const authenticateOwnerSession = Effect.gen(function* () {
31 const request = yield* HttpServerRequest.HttpServerRequest;
32 const serverAuth = yield* ServerAuth;
33 const session = yield* serverAuth.authenticateHttpRequest(request);
34 if (session.role !== "owner") {
35 return yield* new OrchestrationDispatchCommandError({
36 message: "Only owner sessions can manage projects.",
37 });
38 }
39 return session;
40});
41
42export const orchestrationSnapshotRouteLayer = HttpRouter.add(
43 "GET",
44 "/api/orchestration/snapshot",
45 Effect.gen(function* () {
46 yield* authenticateOwnerSession;
47 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;23 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
48 const snapshot = yield* projectionSnapshotQuery.getSnapshot().pipe(
49 Effect.mapError(
50 (cause) =>
51 new OrchestrationGetSnapshotError({
52 message: "Failed to load orchestration snapshot.",
53 cause,
54 }),
55 ),
56 );
57 return HttpServerResponse.jsonUnsafe(snapshot satisfies OrchestrationReadModel, {
58 status: 200,
59 });
60 }).pipe(
61 Effect.catchTags({
62 OrchestrationDispatchCommandError: respondToOrchestrationHttpError,
63 OrchestrationGetSnapshotError: respondToOrchestrationHttpError,
64 }),
65 ),
66);
67
68export const orchestrationDispatchRouteLayer = HttpRouter.add(
69 "POST",
70 "/api/orchestration/dispatch",
71 Effect.gen(function* () {
72 yield* authenticateOwnerSession;
73 const orchestrationEngine = yield* OrchestrationEngineService;24 const orchestrationEngine = yield* OrchestrationEngineService;
74 const command = yield* HttpServerRequest.schemaBodyJson(ClientOrchestrationCommand).pipe(25
75 Effect.mapError(26 return handlers
76 (cause) =>27 .handle(
77 new OrchestrationDispatchCommandError({28 "snapshot",
78 message: "Invalid orchestration command payload.",29 Effect.fn("environment.orchestration.snapshot")(function* (args) {
79 cause,30 yield* annotateEnvironmentRequest(args.endpoint.name);
80 }),31 yield* requireEnvironmentScope(AuthOrchestrationReadScope);
81 ),32 return yield* projectionSnapshotQuery
82 );33 .getSnapshot()
83 const normalizedCommand = yield* normalizeDispatchCommand(command);34 .pipe(
84 const result = yield* orchestrationEngine.dispatch(normalizedCommand).pipe(35 Effect.catch((cause) =>
85 Effect.mapError(36 failEnvironmentInternal("orchestration_snapshot_failed", cause),
86 (cause) =>37 ),
87 new OrchestrationDispatchCommandError({38 );
88 message: "Failed to dispatch orchestration command.",39 }),
89 cause,40 )
90 }),41 .handle(
91 ),42 "dispatch",
92 );43 Effect.fn("environment.orchestration.dispatch")(function* (args) {
93 return HttpServerResponse.jsonUnsafe(result, { status: 200 });44 yield* annotateEnvironmentRequest(args.endpoint.name);
94 }).pipe(Effect.catchTag("OrchestrationDispatchCommandError", respondToOrchestrationHttpError)),45 yield* requireEnvironmentScope(AuthOrchestrationOperateScope);
46 const normalizedCommand = yield* normalizeDispatchCommand(args.payload).pipe(
47 Effect.catch(() => failEnvironmentInvalidRequest("invalid_command")),
48 );
49 return yield* orchestrationEngine
50 .dispatch(normalizedCommand)
51 .pipe(
52 Effect.catch((cause) =>
53 failEnvironmentInternal("orchestration_dispatch_failed", cause),
54 ),
55 );
56 }),
57 );
58 }),
95);59);
apps/server/src/persistence/Layers/AuthPairingLinks.ts+5-5
...@@ -38,7 +38,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () {...@@ -38,7 +38,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () {
38 id,38 id,
39 credential,39 credential,
40 method,40 method,
41 role,41 scopes,
42 subject,42 subject,
43 label,43 label,
44 created_at,44 created_at,
...@@ -50,7 +50,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () {...@@ -50,7 +50,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () {
50 ${input.id},50 ${input.id},
51 ${input.credential},51 ${input.credential},
52 ${input.method},52 ${input.method},
53 ${input.role},53 ${JSON.stringify(input.scopes)},
54 ${input.subject},54 ${input.subject},
55 ${input.label},55 ${input.label},
56 ${input.createdAt},56 ${input.createdAt},
...@@ -76,7 +76,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () {...@@ -76,7 +76,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () {
76 id AS "id",76 id AS "id",
77 credential AS "credential",77 credential AS "credential",
78 method AS "method",78 method AS "method",
79 role AS "role",79 scopes AS "scopes",
80 subject AS "subject",80 subject AS "subject",
81 label AS "label",81 label AS "label",
82 created_at AS "createdAt",82 created_at AS "createdAt",
...@@ -95,7 +95,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () {...@@ -95,7 +95,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () {
95 id AS "id",95 id AS "id",
96 credential AS "credential",96 credential AS "credential",
97 method AS "method",97 method AS "method",
98 role AS "role",98 scopes AS "scopes",
99 subject AS "subject",99 subject AS "subject",
100 label AS "label",100 label AS "label",
101 created_at AS "createdAt",101 created_at AS "createdAt",
...@@ -133,7 +133,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () {...@@ -133,7 +133,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () {
133 id AS "id",133 id AS "id",
134 credential AS "credential",134 credential AS "credential",
135 method AS "method",135 method AS "method",
136 role AS "role",136 scopes AS "scopes",
137 subject AS "subject",137 subject AS "subject",
138 label AS "label",138 label AS "label",
139 created_at AS "createdAt",139 created_at AS "createdAt",
apps/server/src/persistence/Layers/AuthSessions.ts+8-8
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { AuthSessionId } from "@t3tools/contracts";1import { AuthEnvironmentScopes, AuthSessionId, ServerAuthSessionMethod } from "@t3tools/contracts";
2import * as SqlClient from "effect/unstable/sql/SqlClient";2import * as SqlClient from "effect/unstable/sql/SqlClient";
3import * as SqlSchema from "effect/unstable/sql/SqlSchema";3import * as SqlSchema from "effect/unstable/sql/SqlSchema";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
...@@ -26,8 +26,8 @@ import {...@@ -26,8 +26,8 @@ import {
26const AuthSessionDbRow = Schema.Struct({26const AuthSessionDbRow = Schema.Struct({
27 sessionId: AuthSessionId,27 sessionId: AuthSessionId,
28 subject: Schema.String,28 subject: Schema.String,
29 role: Schema.Literals(["owner", "client"]),29 scopes: Schema.fromJsonString(AuthEnvironmentScopes),
30 method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]),30 method: ServerAuthSessionMethod,
31 clientLabel: Schema.NullOr(Schema.String),31 clientLabel: Schema.NullOr(Schema.String),
32 clientIpAddress: Schema.NullOr(Schema.String),32 clientIpAddress: Schema.NullOr(Schema.String),
33 clientUserAgent: Schema.NullOr(Schema.String),33 clientUserAgent: Schema.NullOr(Schema.String),
...@@ -44,7 +44,7 @@ function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionReco...@@ -44,7 +44,7 @@ function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionReco
44 return {44 return {
45 sessionId: row.sessionId,45 sessionId: row.sessionId,
46 subject: row.subject,46 subject: row.subject,
47 role: row.role,47 scopes: row.scopes,
48 method: row.method,48 method: row.method,
49 client: {49 client: {
50 label: row.clientLabel,50 label: row.clientLabel,
...@@ -78,7 +78,7 @@ const makeAuthSessionRepository = Effect.gen(function* () {...@@ -78,7 +78,7 @@ const makeAuthSessionRepository = Effect.gen(function* () {
78 INSERT INTO auth_sessions (78 INSERT INTO auth_sessions (
79 session_id,79 session_id,
80 subject,80 subject,
81 role,81 scopes,
82 method,82 method,
83 client_label,83 client_label,
84 client_ip_address,84 client_ip_address,
...@@ -93,7 +93,7 @@ const makeAuthSessionRepository = Effect.gen(function* () {...@@ -93,7 +93,7 @@ const makeAuthSessionRepository = Effect.gen(function* () {
93 VALUES (93 VALUES (
94 ${input.sessionId},94 ${input.sessionId},
95 ${input.subject},95 ${input.subject},
96 ${input.role},96 ${JSON.stringify(input.scopes)},
97 ${input.method},97 ${input.method},
98 ${input.client.label},98 ${input.client.label},
99 ${input.client.ipAddress},99 ${input.client.ipAddress},
...@@ -116,7 +116,7 @@ const makeAuthSessionRepository = Effect.gen(function* () {...@@ -116,7 +116,7 @@ const makeAuthSessionRepository = Effect.gen(function* () {
116 SELECT116 SELECT
117 session_id AS "sessionId",117 session_id AS "sessionId",
118 subject AS "subject",118 subject AS "subject",
119 role AS "role",119 scopes AS "scopes",
120 method AS "method",120 method AS "method",
121 client_label AS "clientLabel",121 client_label AS "clientLabel",
122 client_ip_address AS "clientIpAddress",122 client_ip_address AS "clientIpAddress",
...@@ -141,7 +141,7 @@ const makeAuthSessionRepository = Effect.gen(function* () {...@@ -141,7 +141,7 @@ const makeAuthSessionRepository = Effect.gen(function* () {
141 SELECT141 SELECT
142 session_id AS "sessionId",142 session_id AS "sessionId",
143 subject AS "subject",143 subject AS "subject",
144 role AS "role",144 scopes AS "scopes",
145 method AS "method",145 method AS "method",
146 client_label AS "clientLabel",146 client_label AS "clientLabel",
147 client_ip_address AS "clientIpAddress",147 client_ip_address AS "clientIpAddress",
apps/server/src/persistence/Migrations.ts+2
...@@ -43,6 +43,7 @@ import Migration0027 from "./Migrations/027_ProviderSessionRuntimeInstanceId.ts"...@@ -43,6 +43,7 @@ import Migration0027 from "./Migrations/027_ProviderSessionRuntimeInstanceId.ts"
43import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts";43import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts";
44import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts";44import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts";
45import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts";45import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts";
46import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts";
4647
47/**48/**
48 * Migration loader with all migrations defined inline.49 * Migration loader with all migrations defined inline.
...@@ -85,6 +86,7 @@ export const migrationEntries = [...@@ -85,6 +86,7 @@ export const migrationEntries = [
85 [28, "ProjectionThreadSessionInstanceId", Migration0028],86 [28, "ProjectionThreadSessionInstanceId", Migration0028],
86 [29, "ProjectionThreadDetailOrderingIndexes", Migration0029],87 [29, "ProjectionThreadDetailOrderingIndexes", Migration0029],
87 [30, "ProjectionThreadShellArchiveIndexes", Migration0030],88 [30, "ProjectionThreadShellArchiveIndexes", Migration0030],
89 [31, "AuthAuthorizationScopes", Migration0031],
88] as const;90] as const;
8991
90export const makeMigrationLoader = (throughId?: number) =>92export const makeMigrationLoader = (throughId?: number) =>
apps/server/src/persistence/Migrations/031_AuthAuthorizationScopes.test.ts created+80
...@@ -0,0 +1,80 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as SqlClient from "effect/unstable/sql/SqlClient";
5
6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "../NodeSqliteClient.ts";
8
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
10
11layer("031_AuthAuthorizationScopes", (it) => {
12 it.effect("invalidates role-based auth records and installs scoped auth tables", () =>
13 Effect.gen(function* () {
14 const sql = yield* SqlClient.SqlClient;
15
16 yield* runMigrations({ toMigrationInclusive: 30 });
17
18 yield* sql`
19 INSERT INTO auth_pairing_links (
20 id,
21 credential,
22 method,
23 role,
24 subject,
25 created_at,
26 expires_at
27 )
28 VALUES (
29 'link-owner',
30 'bootstrap-owner',
31 'desktop-bootstrap',
32 'owner',
33 'desktop',
34 '2026-05-29T00:00:00.000Z',
35 '2026-05-29T01:00:00.000Z'
36 )
37 `;
38 yield* sql`
39 INSERT INTO auth_sessions (
40 session_id,
41 subject,
42 role,
43 method,
44 issued_at,
45 expires_at
46 )
47 VALUES (
48 'session-owner',
49 'desktop',
50 'owner',
51 'browser-session-cookie',
52 '2026-05-29T00:00:00.000Z',
53 '2026-05-29T01:00:00.000Z'
54 )
55 `;
56
57 yield* runMigrations({ toMigrationInclusive: 31 });
58
59 const pairingColumns = yield* sql<{ readonly name: string }>`
60 PRAGMA table_info(auth_pairing_links)
61 `;
62 const sessionColumns = yield* sql<{ readonly name: string }>`
63 PRAGMA table_info(auth_sessions)
64 `;
65 const pairingRows = yield* sql<{ readonly id: string }>`
66 SELECT id FROM auth_pairing_links
67 `;
68 const sessionRows = yield* sql<{ readonly sessionId: string }>`
69 SELECT session_id AS "sessionId" FROM auth_sessions
70 `;
71
72 assert.isTrue(pairingColumns.some((column) => column.name === "scopes"));
73 assert.isFalse(pairingColumns.some((column) => column.name === "role"));
74 assert.isTrue(sessionColumns.some((column) => column.name === "scopes"));
75 assert.isFalse(sessionColumns.some((column) => column.name === "role"));
76 assert.deepStrictEqual(pairingRows, []);
77 assert.deepStrictEqual(sessionRows, []);
78 }),
79 );
80});
apps/server/src/persistence/Migrations/031_AuthAuthorizationScopes.ts created+57
...@@ -0,0 +1,57 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4/**
5 * This is an intentional alpha cutover: role-bearing credentials and sessions
6 * cannot safely be assigned new capabilities implicitly.
7 */
8export default Effect.gen(function* () {
9 const sql = yield* SqlClient.SqlClient;
10
11 yield* sql`DROP TABLE IF EXISTS auth_pairing_links`;
12 yield* sql`DROP TABLE IF EXISTS auth_sessions`;
13
14 yield* sql`
15 CREATE TABLE auth_pairing_links (
16 id TEXT PRIMARY KEY,
17 credential TEXT NOT NULL UNIQUE,
18 method TEXT NOT NULL,
19 scopes TEXT NOT NULL,
20 subject TEXT NOT NULL,
21 label TEXT,
22 created_at TEXT NOT NULL,
23 expires_at TEXT NOT NULL,
24 consumed_at TEXT,
25 revoked_at TEXT
26 )
27 `;
28
29 yield* sql`
30 CREATE INDEX idx_auth_pairing_links_active
31 ON auth_pairing_links(revoked_at, consumed_at, expires_at)
32 `;
33
34 yield* sql`
35 CREATE TABLE auth_sessions (
36 session_id TEXT PRIMARY KEY,
37 subject TEXT NOT NULL,
38 scopes TEXT NOT NULL,
39 method TEXT NOT NULL,
40 client_label TEXT,
41 client_ip_address TEXT,
42 client_user_agent TEXT,
43 client_device_type TEXT NOT NULL DEFAULT 'unknown',
44 client_os TEXT,
45 client_browser TEXT,
46 issued_at TEXT NOT NULL,
47 expires_at TEXT NOT NULL,
48 last_connected_at TEXT,
49 revoked_at TEXT
50 )
51 `;
52
53 yield* sql`
54 CREATE INDEX idx_auth_sessions_active
55 ON auth_sessions(revoked_at, expires_at, issued_at)
56 `;
57});
apps/server/src/persistence/Services/AuthPairingLinks.ts+3-2
...@@ -2,6 +2,7 @@ import * as Option from "effect/Option";...@@ -2,6 +2,7 @@ import * as Option from "effect/Option";
2import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
3import * as Context from "effect/Context";3import * as Context from "effect/Context";
4import type * as Effect from "effect/Effect";4import type * as Effect from "effect/Effect";
5import { AuthEnvironmentScopes } from "@t3tools/contracts";
56
6import type { AuthPairingLinkRepositoryError } from "../Errors.ts";7import type { AuthPairingLinkRepositoryError } from "../Errors.ts";
78
...@@ -9,7 +10,7 @@ export const AuthPairingLinkRecord = Schema.Struct({...@@ -9,7 +10,7 @@ export const AuthPairingLinkRecord = Schema.Struct({
9 id: Schema.String,10 id: Schema.String,
10 credential: Schema.String,11 credential: Schema.String,
11 method: Schema.Literals(["desktop-bootstrap", "one-time-token"]),12 method: Schema.Literals(["desktop-bootstrap", "one-time-token"]),
12 role: Schema.Literals(["owner", "client"]),13 scopes: Schema.fromJsonString(AuthEnvironmentScopes),
13 subject: Schema.String,14 subject: Schema.String,
14 label: Schema.NullOr(Schema.String),15 label: Schema.NullOr(Schema.String),
15 createdAt: Schema.DateTimeUtcFromString,16 createdAt: Schema.DateTimeUtcFromString,
...@@ -23,7 +24,7 @@ export const CreateAuthPairingLinkInput = Schema.Struct({...@@ -23,7 +24,7 @@ export const CreateAuthPairingLinkInput = Schema.Struct({
23 id: Schema.String,24 id: Schema.String,
24 credential: Schema.String,25 credential: Schema.String,
25 method: Schema.Literals(["desktop-bootstrap", "one-time-token"]),26 method: Schema.Literals(["desktop-bootstrap", "one-time-token"]),
26 role: Schema.Literals(["owner", "client"]),27 scopes: AuthEnvironmentScopes,
27 subject: Schema.String,28 subject: Schema.String,
28 label: Schema.NullOr(Schema.String),29 label: Schema.NullOr(Schema.String),
29 createdAt: Schema.DateTimeUtcFromString,30 createdAt: Schema.DateTimeUtcFromString,
apps/server/src/persistence/Services/AuthSessions.ts+10-5
...@@ -1,4 +1,9 @@...@@ -1,4 +1,9 @@
1import { AuthClientMetadataDeviceType, AuthSessionId } from "@t3tools/contracts";1import {
2 AuthClientMetadataDeviceType,
3 AuthEnvironmentScopes,
4 AuthSessionId,
5 ServerAuthSessionMethod,
6} from "@t3tools/contracts";
2import * as Option from "effect/Option";7import * as Option from "effect/Option";
3import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
4import * as Context from "effect/Context";9import * as Context from "effect/Context";
...@@ -19,8 +24,8 @@ export type AuthSessionClientMetadataRecord = typeof AuthSessionClientMetadataRe...@@ -19,8 +24,8 @@ export type AuthSessionClientMetadataRecord = typeof AuthSessionClientMetadataRe
19export const AuthSessionRecord = Schema.Struct({24export const AuthSessionRecord = Schema.Struct({
20 sessionId: AuthSessionId,25 sessionId: AuthSessionId,
21 subject: Schema.String,26 subject: Schema.String,
22 role: Schema.Literals(["owner", "client"]),27 scopes: AuthEnvironmentScopes,
23 method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]),28 method: ServerAuthSessionMethod,
24 client: AuthSessionClientMetadataRecord,29 client: AuthSessionClientMetadataRecord,
25 issuedAt: Schema.DateTimeUtcFromString,30 issuedAt: Schema.DateTimeUtcFromString,
26 expiresAt: Schema.DateTimeUtcFromString,31 expiresAt: Schema.DateTimeUtcFromString,
...@@ -32,8 +37,8 @@ export type AuthSessionRecord = typeof AuthSessionRecord.Type;...@@ -32,8 +37,8 @@ export type AuthSessionRecord = typeof AuthSessionRecord.Type;
32export const CreateAuthSessionInput = Schema.Struct({37export const CreateAuthSessionInput = Schema.Struct({
33 sessionId: AuthSessionId,38 sessionId: AuthSessionId,
34 subject: Schema.String,39 subject: Schema.String,
35 role: Schema.Literals(["owner", "client"]),40 scopes: AuthEnvironmentScopes,
36 method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]),41 method: ServerAuthSessionMethod,
37 client: AuthSessionClientMetadataRecord,42 client: AuthSessionClientMetadataRecord,
38 issuedAt: Schema.DateTimeUtcFromString,43 issuedAt: Schema.DateTimeUtcFromString,
39 expiresAt: Schema.DateTimeUtcFromString,44 expiresAt: Schema.DateTimeUtcFromString,
apps/server/src/server.test.ts+395-93
...@@ -3,6 +3,9 @@ import * as NodeSocket from "@effect/platform-node/NodeSocket";...@@ -3,6 +3,9 @@ import * as NodeSocket from "@effect/platform-node/NodeSocket";
3import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
44
5import {5import {
6 AuthAccessTokenType,
7 AuthEnvironmentBootstrapTokenType,
8 AuthTokenExchangeGrantType,
6 CommandId,9 CommandId,
7 DEFAULT_SERVER_SETTINGS,10 DEFAULT_SERVER_SETTINGS,
8 EnvironmentId,11 EnvironmentId,
...@@ -46,6 +49,7 @@ import {...@@ -46,6 +49,7 @@ import {
46 HttpClient,49 HttpClient,
47 HttpRouter,50 HttpRouter,
48 HttpServer,51 HttpServer,
52 UrlParams,
49} from "effect/unstable/http";53} from "effect/unstable/http";
50import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";54import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
51import { RpcClient, RpcSerialization } from "effect/unstable/rpc";55import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
...@@ -114,8 +118,8 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";...@@ -114,8 +118,8 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";
114import * as GitWorkflowService from "./git/GitWorkflowService.ts";118import * as GitWorkflowService from "./git/GitWorkflowService.ts";
115import * as ReviewService from "./review/ReviewService.ts";119import * as ReviewService from "./review/ReviewService.ts";
116import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";120import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
117import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";121import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
118import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts";122import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
119import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";123import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
120import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";124import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
121import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";125import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
...@@ -215,7 +219,10 @@ const browserOtlpTracingLayer = Layer.mergeAll(...@@ -215,7 +219,10 @@ const browserOtlpTracingLayer = Layer.mergeAll(
215);219);
216220
217const makeAuthTestLayer = () =>221const makeAuthTestLayer = () =>
218 ServerAuthLive.pipe(Layer.provide(SqlitePersistenceMemory), Layer.provide(ServerSecretStoreLive));222 EnvironmentAuth.layer.pipe(
223 Layer.provide(SqlitePersistenceMemory),
224 Layer.provide(ServerSecretStore.layer),
225 );
219226
220const makeBrowserOtlpPayload = (spanName: string) =>227const makeBrowserOtlpPayload = (spanName: string) =>
221 Effect.gen(function* () {228 Effect.gen(function* () {
...@@ -799,7 +806,7 @@ const bootstrapBrowserSession = (...@@ -799,7 +806,7 @@ const bootstrapBrowserSession = (
799 },806 },
800) =>807) =>
801 Effect.gen(function* () {808 Effect.gen(function* () {
802 const response = yield* HttpClient.post("/api/auth/bootstrap", {809 const response = yield* HttpClient.post("/api/auth/browser-session", {
803 headers: options?.headers,810 headers: options?.headers,
804 body: yield* HttpBody.json({ credential }),811 body: yield* HttpBody.json({ credential }),
805 });812 });
...@@ -815,23 +822,46 @@ const bootstrapBrowserSession = (...@@ -815,23 +822,46 @@ const bootstrapBrowserSession = (
815 };822 };
816 });823 });
817824
818const bootstrapBearerSession = (825const exchangeAccessToken = (
819 credential = defaultDesktopBootstrapToken,826 credential = defaultDesktopBootstrapToken,
820 options?: {827 options?: {
821 readonly headers?: Record<string, string>;828 readonly headers?: Record<string, string>;
829 readonly scope?: string;
830 readonly clientMetadata?: {
831 readonly label?: string;
832 readonly deviceType?: string;
833 readonly os?: string;
834 };
822 },835 },
823) =>836) =>
824 Effect.gen(function* () {837 Effect.gen(function* () {
825 const response = yield* HttpClient.post("/api/auth/bootstrap/bearer", {838 const response = yield* HttpClient.post("/oauth/token", {
826 headers: options?.headers,839 headers: options?.headers,
827 body: yield* HttpBody.json({ credential }),840 body: HttpBody.urlParams(
841 UrlParams.fromInput({
842 grant_type: AuthTokenExchangeGrantType,
843 subject_token: credential,
844 subject_token_type: AuthEnvironmentBootstrapTokenType,
845 requested_token_type: AuthAccessTokenType,
846 scope:
847 options?.scope ??
848 "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write",
849 ...(options?.clientMetadata?.label ? { client_label: options.clientMetadata.label } : {}),
850 ...(options?.clientMetadata?.deviceType
851 ? { client_device_type: options.clientMetadata.deviceType }
852 : {}),
853 ...(options?.clientMetadata?.os ? { client_os: options.clientMetadata.os } : {}),
854 }),
855 ),
828 });856 });
829 const body = (yield* response.json) as {857 const body = (yield* response.json) as {
830 readonly authenticated: boolean;858 readonly access_token?: string;
831 readonly sessionMethod: string;859 readonly issued_token_type?: string;
832 readonly expiresAt: string;860 readonly token_type?: string;
833 readonly sessionToken?: string;861 readonly expires_in?: number;
834 readonly error?: string;862 readonly scope?: string;
863 readonly _tag?: string;
864 readonly message?: string;
835 };865 };
836 return {866 return {
837 response,867 response,
...@@ -863,20 +893,20 @@ const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstra...@@ -863,20 +893,20 @@ const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstra
863893
864const getAuthenticatedBearerSessionToken = (credential = defaultDesktopBootstrapToken) =>894const getAuthenticatedBearerSessionToken = (credential = defaultDesktopBootstrapToken) =>
865 Effect.gen(function* () {895 Effect.gen(function* () {
866 const { response, body } = yield* bootstrapBearerSession(credential);896 const { response, body } = yield* exchangeAccessToken(credential);
867 if (response.status !== 200) {897 if (response.status !== 200) {
868 return yield* new AuthenticationGetterError({898 return yield* new AuthenticationGetterError({
869 message: `Expected bearer bootstrap response to succeed, got ${response.status}`,899 message: `Expected bearer bootstrap response to succeed, got ${response.status}`,
870 });900 });
871 }901 }
872902
873 if (!body.sessionToken) {903 if (!body.access_token) {
874 return yield* new AuthenticationGetterError({904 return yield* new AuthenticationGetterError({
875 message: "Expected bearer bootstrap response to include a session token.",905 message: "Expected token exchange response to include an access token.",
876 });906 });
877 }907 }
878908
879 return body.sessionToken;909 return body.access_token;
880 });910 });
881911
882const extractSessionTokenFromSetCookie = (cookieHeader: string): string => {912const extractSessionTokenFromSetCookie = (cookieHeader: string): string => {
...@@ -895,8 +925,16 @@ const splitHeaderTokens = (value: string | null) =>...@@ -895,8 +925,16 @@ const splitHeaderTokens = (value: string | null) =>
895 .filter((token) => token.length > 0)925 .filter((token) => token.length > 0)
896 .toSorted();926 .toSorted();
897927
898const assertBrowserApiCorsHeaders = (headers: Readonly<Record<string, string | undefined>>) => {928const assertBrowserApiCorsResponseHeaders = (
929 headers: Readonly<Record<string, string | undefined>>,
930) => {
899 assert.equal(headers["access-control-allow-origin"], "*");931 assert.equal(headers["access-control-allow-origin"], "*");
932};
933
934const assertBrowserApiCorsPreflightHeaders = (
935 headers: Readonly<Record<string, string | undefined>>,
936) => {
937 assertBrowserApiCorsResponseHeaders(headers);
900 assert.deepEqual(splitHeaderTokens(headers["access-control-allow-methods"] ?? null), [938 assert.deepEqual(splitHeaderTokens(headers["access-control-allow-methods"] ?? null), [
901 "GET",939 "GET",
902 "OPTIONS",940 "OPTIONS",
...@@ -1039,7 +1077,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1039,7 +1077,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1039 const body = (yield* response.json) as typeof testEnvironmentDescriptor;1077 const body = (yield* response.json) as typeof testEnvironmentDescriptor;
10401078
1041 assert.equal(response.status, 200);1079 assert.equal(response.status, 200);
1042 assertBrowserApiCorsHeaders(response.headers);1080 assertBrowserApiCorsResponseHeaders(response.headers);
1043 assert.deepEqual(body, testEnvironmentDescriptor);1081 assert.deepEqual(body, testEnvironmentDescriptor);
1044 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1082 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1045 );1083 );
...@@ -1063,10 +1101,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1063,10 +1101,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1063 assert.equal(body.authenticated, false);1101 assert.equal(body.authenticated, false);
1064 assert.equal(body.auth.policy, "desktop-managed-local");1102 assert.equal(body.auth.policy, "desktop-managed-local");
1065 assert.deepEqual(body.auth.bootstrapMethods, ["desktop-bootstrap"]);1103 assert.deepEqual(body.auth.bootstrapMethods, ["desktop-bootstrap"]);
1066 assert.deepEqual(body.auth.sessionMethods, [1104 assert.deepEqual(body.auth.sessionMethods, ["browser-session-cookie", "bearer-access-token"]);
1067 "browser-session-cookie",
1068 "bearer-session-token",
1069 ]);
1070 assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_"));1105 assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_"));
1071 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1106 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1072 );1107 );
...@@ -1103,56 +1138,134 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1103,56 +1138,134 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1103 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1138 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1104 );1139 );
11051140
1106 it.effect(1141 it.effect("exchanges a bootstrap grant for a scoped bearer access token", () =>
1107 "bootstraps a bearer session and authenticates the session endpoint via authorization header",1142 Effect.gen(function* () {
1108 () =>1143 yield* buildAppUnderTest();
1109 Effect.gen(function* () {
1110 yield* buildAppUnderTest();
11111144
1112 const { response: bootstrapResponse, body: bootstrapBody } =1145 const { response: bootstrapResponse, body: tokenBody } = yield* exchangeAccessToken();
1113 yield* bootstrapBearerSession();
11141146
1115 assert.equal(bootstrapResponse.status, 200);1147 assert.equal(bootstrapResponse.status, 200);
1116 assert.equal(bootstrapBody.authenticated, true);1148 assert.equal(tokenBody.issued_token_type, AuthAccessTokenType);
1117 assert.equal(bootstrapBody.sessionMethod, "bearer-session-token");1149 assert.equal(tokenBody.token_type, "Bearer");
1118 assert.equal(typeof bootstrapBody.sessionToken, "string");1150 assert.equal(
1119 assert.isTrue((bootstrapBody.sessionToken?.length ?? 0) > 0);1151 tokenBody.scope,
1152 "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write",
1153 );
1154 assert.equal(typeof tokenBody.access_token, "string");
1155 assert.isTrue((tokenBody.access_token?.length ?? 0) > 0);
11201156
1121 const sessionResponse = yield* HttpClient.get("/api/auth/session", {1157 const sessionResponse = yield* HttpClient.get("/api/auth/session", {
1122 headers: {1158 headers: {
1123 authorization: `Bearer ${bootstrapBody.sessionToken ?? ""}`,1159 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
1124 },1160 },
1125 });1161 });
1126 const sessionBody = (yield* sessionResponse.json) as {1162 const sessionBody = (yield* sessionResponse.json) as {
1127 readonly authenticated: boolean;1163 readonly authenticated: boolean;
1128 readonly sessionMethod?: string;1164 readonly sessionMethod?: string;
1129 };1165 readonly scopes?: ReadonlyArray<string>;
1166 };
11301167
1131 assert.equal(sessionResponse.status, 200);1168 assert.equal(sessionResponse.status, 200);
1132 assert.equal(sessionBody.authenticated, true);1169 assert.equal(sessionBody.authenticated, true);
1133 assert.equal(sessionBody.sessionMethod, "bearer-session-token");1170 assert.equal(sessionBody.sessionMethod, "bearer-access-token");
1134 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1171 assert.deepEqual(sessionBody.scopes, [
1172 "orchestration:read",
1173 "orchestration:operate",
1174 "terminal:operate",
1175 "review:write",
1176 "relay:read",
1177 "access:read",
1178 "access:write",
1179 "relay:write",
1180 ]);
1181 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1135 );1182 );
11361183
1137 it.effect("issues short-lived websocket tokens for authenticated bearer sessions", () =>1184 it.effect("issues short-lived websocket tickets for authenticated bearer sessions", () =>
1138 Effect.gen(function* () {1185 Effect.gen(function* () {
1139 yield* buildAppUnderTest();1186 yield* buildAppUnderTest();
11401187
1141 const bearerToken = yield* getAuthenticatedBearerSessionToken();1188 const bearerToken = yield* getAuthenticatedBearerSessionToken();
1142 const wsTokenResponse = yield* HttpClient.post("/api/auth/ws-token", {1189 const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", {
1143 headers: {1190 headers: {
1144 authorization: `Bearer ${bearerToken}`,1191 authorization: `Bearer ${bearerToken}`,
1145 },1192 },
1146 });1193 });
1147 const wsTokenBody = (yield* wsTokenResponse.json) as {1194 const wsTicketBody = (yield* wsTicketResponse.json) as {
1148 readonly token: string;1195 readonly ticket: string;
1149 readonly expiresAt: string;1196 readonly expiresAt: string;
1150 };1197 };
11511198
1152 assert.equal(wsTokenResponse.status, 200);1199 assert.equal(wsTicketResponse.status, 200);
1153 assert.equal(typeof wsTokenBody.token, "string");1200 assert.equal(typeof wsTicketBody.ticket, "string");
1154 assert.isTrue(wsTokenBody.token.length > 0);1201 assert.isTrue(wsTicketBody.ticket.length > 0);
1155 assert.equal(typeof wsTokenBody.expiresAt, "string");1202 assert.equal(typeof wsTicketBody.expiresAt, "string");
1203 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1204 );
1205
1206 it.effect("does not allow management-only access tokens to operate the environment", () =>
1207 Effect.gen(function* () {
1208 yield* buildAppUnderTest();
1209
1210 const { response: exchangeResponse, body: tokenBody } = yield* exchangeAccessToken(
1211 defaultDesktopBootstrapToken,
1212 { scope: "access:write" },
1213 );
1214 assert.equal(exchangeResponse.status, 200);
1215 assert.equal(tokenBody.scope, "access:write");
1216 assert.isDefined(tokenBody.access_token);
1217
1218 const overbroadPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", {
1219 headers: {
1220 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
1221 },
1222 body: yield* HttpBody.json({}),
1223 });
1224 const overbroadPairingBody = (yield* overbroadPairingResponse.json) as {
1225 readonly requiredScope: string;
1226 };
1227 const pairingResponse = yield* HttpClient.post("/api/auth/pairing-token", {
1228 headers: {
1229 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
1230 },
1231 body: yield* HttpBody.json({ scopes: ["access:write"] }),
1232 });
1233 const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", {
1234 headers: {
1235 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
1236 },
1237 });
1238 const wsTicketBody = (yield* wsTicketResponse.json) as { readonly ticket: string };
1239 const faviconResponse = yield* HttpClient.get("/api/project-favicon?cwd=/tmp", {
1240 headers: {
1241 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
1242 },
1243 });
1244 const faviconBody = (yield* faviconResponse.json) as {
1245 readonly _tag: string;
1246 readonly code: string;
1247 readonly requiredScope: string;
1248 readonly traceId: string;
1249 };
1250
1251 assert.equal(overbroadPairingResponse.status, 403);
1252 assert.equal(overbroadPairingBody.requiredScope, "orchestration:read");
1253 assert.equal(pairingResponse.status, 200);
1254 assert.equal(wsTicketResponse.status, 200);
1255 assert.equal(faviconResponse.status, 403);
1256 assert.equal(faviconBody._tag, "EnvironmentScopeRequiredError");
1257 assert.equal(faviconBody.code, "insufficient_scope");
1258 assert.equal(faviconBody.requiredScope, "orchestration:read");
1259 assert.equal(typeof faviconBody.traceId, "string");
1260
1261 const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`;
1262 const rpcError = yield* Effect.flip(
1263 Effect.scoped(withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({}))),
1264 );
1265 assert.equal(rpcError._tag, "EnvironmentAuthorizationError");
1266 if (rpcError._tag === "EnvironmentAuthorizationError") {
1267 assert.equal(rpcError.requiredScope, "orchestration:read");
1268 }
1156 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1269 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1157 );1270 );
11581271
...@@ -1161,7 +1274,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1161,7 +1274,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1161 yield* buildAppUnderTest();1274 yield* buildAppUnderTest();
11621275
1163 const origin = crossOriginClientOrigin;1276 const origin = crossOriginClientOrigin;
1164 const { response: bootstrapResponse, body: bootstrapBody } = yield* bootstrapBearerSession(1277 const { response: bootstrapResponse, body: tokenBody } = yield* exchangeAccessToken(
1165 defaultDesktopBootstrapToken,1278 defaultDesktopBootstrapToken,
1166 {1279 {
1167 headers: { origin },1280 headers: { origin },
...@@ -1169,13 +1282,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1169,13 +1282,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1169 );1282 );
11701283
1171 assert.equal(bootstrapResponse.status, 200);1284 assert.equal(bootstrapResponse.status, 200);
1172 assertBrowserApiCorsHeaders(bootstrapResponse.headers);1285 assertBrowserApiCorsResponseHeaders(bootstrapResponse.headers);
1173 assert.equal(bootstrapBody.authenticated, true);1286 assert.equal(tokenBody.token_type, "Bearer");
1174 assert.equal(typeof bootstrapBody.sessionToken, "string");1287 assert.equal(typeof tokenBody.access_token, "string");
11751288
1176 const sessionResponse = yield* HttpClient.get("/api/auth/session", {1289 const sessionResponse = yield* HttpClient.get("/api/auth/session", {
1177 headers: {1290 headers: {
1178 authorization: `Bearer ${bootstrapBody.sessionToken ?? ""}`,1291 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
1179 origin,1292 origin,
1180 },1293 },
1181 });1294 });
...@@ -1185,33 +1298,33 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1185,33 +1298,33 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1185 };1298 };
11861299
1187 assert.equal(sessionResponse.status, 200);1300 assert.equal(sessionResponse.status, 200);
1188 assertBrowserApiCorsHeaders(sessionResponse.headers);1301 assertBrowserApiCorsResponseHeaders(sessionResponse.headers);
1189 assert.equal(sessionBody.authenticated, true);1302 assert.equal(sessionBody.authenticated, true);
1190 assert.equal(sessionBody.sessionMethod, "bearer-session-token");1303 assert.equal(sessionBody.sessionMethod, "bearer-access-token");
11911304
1192 const wsTokenResponse = yield* HttpClient.post("/api/auth/ws-token", {1305 const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", {
1193 headers: {1306 headers: {
1194 authorization: `Bearer ${bootstrapBody.sessionToken ?? ""}`,1307 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
1195 origin,1308 origin,
1196 },1309 },
1197 });1310 });
1198 const wsTokenBody = (yield* wsTokenResponse.json) as {1311 const wsTicketBody = (yield* wsTicketResponse.json) as {
1199 readonly token: string;1312 readonly ticket: string;
1200 };1313 };
12011314
1202 assert.equal(wsTokenResponse.status, 200);1315 assert.equal(wsTicketResponse.status, 200);
1203 assertBrowserApiCorsHeaders(wsTokenResponse.headers);1316 assertBrowserApiCorsResponseHeaders(wsTicketResponse.headers);
1204 assert.equal(typeof wsTokenBody.token, "string");1317 assert.equal(typeof wsTicketBody.ticket, "string");
1205 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1318 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1206 );1319 );
12071320
1208 it.effect(1321 it.effect(
1209 "responds to remote auth websocket-token preflight requests with authorization CORS headers",1322 "responds to remote auth websocket-ticket preflight requests with authorization CORS headers",
1210 () =>1323 () =>
1211 Effect.gen(function* () {1324 Effect.gen(function* () {
1212 yield* buildAppUnderTest();1325 yield* buildAppUnderTest();
12131326
1214 const response = yield* HttpClient.options("/api/auth/ws-token", {1327 const response = yield* HttpClient.options("/api/auth/websocket-ticket", {
1215 headers: {1328 headers: {
1216 origin: crossOriginClientOrigin,1329 origin: crossOriginClientOrigin,
1217 "access-control-request-method": "POST",1330 "access-control-request-method": "POST",
...@@ -1220,26 +1333,93 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1220,26 +1333,93 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1220 });1333 });
12211334
1222 assert.equal(response.status, 204);1335 assert.equal(response.status, 204);
1223 assertBrowserApiCorsHeaders(response.headers);1336 assertBrowserApiCorsPreflightHeaders(response.headers);
1224 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1337 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1225 );1338 );
12261339
1227 it.effect("includes CORS headers on remote websocket-token auth failures", () =>1340 it.effect("includes CORS headers on remote websocket-ticket auth failures", () =>
1228 Effect.gen(function* () {1341 Effect.gen(function* () {
1229 yield* buildAppUnderTest();1342 yield* buildAppUnderTest();
12301343
1231 const response = yield* HttpClient.post("/api/auth/ws-token", {1344 const response = yield* HttpClient.post("/api/auth/websocket-ticket", {
1232 headers: {1345 headers: {
1233 origin: crossOriginClientOrigin,1346 origin: crossOriginClientOrigin,
1234 },1347 },
1235 });1348 });
1236 const body = (yield* response.json) as {1349 const body = (yield* response.json) as {
1237 readonly error?: string;1350 readonly _tag?: string;
1351 readonly code?: string;
1352 readonly reason?: string;
1353 readonly traceId?: string;
1238 };1354 };
12391355
1240 assert.equal(response.status, 401);1356 assert.equal(response.status, 401);
1241 assertBrowserApiCorsHeaders(response.headers);1357 assertBrowserApiCorsResponseHeaders(response.headers);
1242 assert.equal(body.error, "Authentication required.");1358 assert.equal(body._tag, "EnvironmentAuthInvalidError");
1359 assert.equal(body.code, "auth_invalid");
1360 assert.equal(body.reason, "missing_credential");
1361 assert.equal(typeof body.traceId, "string");
1362 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1363 );
1364
1365 it.effect("persists token exchange client display metadata for authorized-client listings", () =>
1366 Effect.gen(function* () {
1367 yield* buildAppUnderTest({
1368 config: {
1369 host: "0.0.0.0",
1370 },
1371 });
1372
1373 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
1374 const pairingResponse = yield* HttpClient.post("/api/auth/pairing-token", {
1375 headers: {
1376 cookie: ownerCookie,
1377 },
1378 body: yield* HttpBody.json({}),
1379 });
1380 const pairingBody = (yield* pairingResponse.json) as {
1381 readonly credential: string;
1382 };
1383
1384 const { response } = yield* exchangeAccessToken(pairingBody.credential, {
1385 headers: {
1386 "user-agent": "undici",
1387 },
1388 scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read",
1389 clientMetadata: {
1390 label: "T3 Code Mobile",
1391 deviceType: "mobile",
1392 os: "iOS",
1393 },
1394 });
1395
1396 const clientsResponse = yield* HttpClient.get("/api/auth/clients", {
1397 headers: {
1398 cookie: ownerCookie,
1399 },
1400 });
1401 const clients = (yield* clientsResponse.json) as ReadonlyArray<{
1402 readonly current: boolean;
1403 readonly client: {
1404 readonly label?: string;
1405 readonly deviceType: string;
1406 readonly ipAddress?: string;
1407 readonly os?: string;
1408 readonly userAgent?: string;
1409 };
1410 }>;
1411 const mobileClient = clients.find((client) => !client.current);
1412
1413 assert.equal(pairingResponse.status, 200);
1414 assert.equal(response.status, 200);
1415 assert.equal(clientsResponse.status, 200);
1416 assert.deepInclude(mobileClient?.client, {
1417 label: "T3 Code Mobile",
1418 deviceType: "mobile",
1419 os: "iOS",
1420 ipAddress: "127.0.0.1",
1421 userAgent: "undici",
1422 });
1243 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1423 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1244 );1424 );
12451425
...@@ -1251,6 +1431,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1251,6 +1431,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1251 headers: {1431 headers: {
1252 cookie: yield* getAuthenticatedSessionCookieHeader(),1432 cookie: yield* getAuthenticatedSessionCookieHeader(),
1253 },1433 },
1434 body: yield* HttpBody.json({}),
1254 });1435 });
1255 const body = (yield* response.json) as {1436 const body = (yield* response.json) as {
1256 readonly credential: string;1437 readonly credential: string;
...@@ -1270,16 +1451,61 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1270,16 +1451,61 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1270 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1451 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1271 );1452 );
12721453
1454 it.effect("issues pairing credentials for bearer sessions with access management scope", () =>
1455 Effect.gen(function* () {
1456 yield* buildAppUnderTest();
1457
1458 const bearerToken = yield* getAuthenticatedBearerSessionToken();
1459 const response = yield* HttpClient.post("/api/auth/pairing-token", {
1460 headers: {
1461 authorization: `Bearer ${bearerToken}`,
1462 },
1463 body: yield* HttpBody.json({ label: "Hosted web" }),
1464 });
1465 const body = (yield* response.json) as {
1466 readonly credential: string;
1467 readonly label?: string;
1468 };
1469
1470 assert.equal(response.status, 200);
1471 assert.isTrue(body.credential.length > 0);
1472 assert.equal(body.label, "Hosted web");
1473 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1474 );
1475
1476 it.effect("rejects pairing credentials with an empty scope grant", () =>
1477 Effect.gen(function* () {
1478 yield* buildAppUnderTest();
1479
1480 const response = yield* HttpClient.post("/api/auth/pairing-token", {
1481 headers: {
1482 cookie: yield* getAuthenticatedSessionCookieHeader(),
1483 },
1484 body: yield* HttpBody.json({ scopes: [] }),
1485 });
1486 const body = (yield* response.json) as {
1487 readonly code: string;
1488 readonly reason: string;
1489 };
1490
1491 assert.equal(response.status, 400);
1492 assert.equal(body.code, "invalid_request");
1493 assert.equal(body.reason, "invalid_scope");
1494 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1495 );
1496
1273 it.effect("rejects unauthenticated pairing credential requests", () =>1497 it.effect("rejects unauthenticated pairing credential requests", () =>
1274 Effect.gen(function* () {1498 Effect.gen(function* () {
1275 yield* buildAppUnderTest();1499 yield* buildAppUnderTest();
12761500
1277 const response = yield* HttpClient.post("/api/auth/pairing-token");1501 const response = yield* HttpClient.post("/api/auth/pairing-token", {
1502 body: yield* HttpBody.json({}),
1503 });
1278 assert.equal(response.status, 401);1504 assert.equal(response.status, 401);
1279 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1505 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1280 );1506 );
12811507
1282 it.effect("lists and revokes pairing links for owner sessions", () =>1508 it.effect("lists and revokes pairing links for access management sessions", () =>
1283 Effect.gen(function* () {1509 Effect.gen(function* () {
1284 yield* buildAppUnderTest({1510 yield* buildAppUnderTest({
1285 config: {1511 config: {
...@@ -1292,6 +1518,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1292,6 +1518,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1292 headers: {1518 headers: {
1293 cookie: ownerCookie,1519 cookie: ownerCookie,
1294 },1520 },
1521 body: yield* HttpBody.json({}),
1295 });1522 });
1296 const createdBody = (yield* createdResponse.json) as {1523 const createdBody = (yield* createdResponse.json) as {
1297 readonly id: string;1524 readonly id: string;
...@@ -1324,7 +1551,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1324,7 +1551,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1324 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1551 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1325 );1552 );
13261553
1327 it.effect("rejects pairing credential requests from non-owner paired sessions", () =>1554 it.effect("rejects pairing credential requests without access management scope", () =>
1328 Effect.gen(function* () {1555 Effect.gen(function* () {
1329 yield* buildAppUnderTest({1556 yield* buildAppUnderTest({
1330 config: {1557 config: {
...@@ -1336,6 +1563,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1336,6 +1563,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1336 headers: {1563 headers: {
1337 cookie: yield* getAuthenticatedSessionCookieHeader(),1564 cookie: yield* getAuthenticatedSessionCookieHeader(),
1338 },1565 },
1566 body: yield* HttpBody.json({}),
1339 });1567 });
1340 const ownerBody = (yield* ownerResponse.json) as {1568 const ownerBody = (yield* ownerResponse.json) as {
1341 readonly credential: string;1569 readonly credential: string;
...@@ -1347,17 +1575,24 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1347,17 +1575,24 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1347 headers: {1575 headers: {
1348 cookie: pairedSessionCookie,1576 cookie: pairedSessionCookie,
1349 },1577 },
1578 body: yield* HttpBody.json({}),
1350 });1579 });
1351 const pairedBody = (yield* pairedResponse.json) as {1580 const pairedBody = (yield* pairedResponse.json) as {
1352 readonly error: string;1581 readonly _tag: string;
1582 readonly code: string;
1583 readonly requiredScope: string;
1584 readonly traceId: string;
1353 };1585 };
13541586
1355 assert.equal(pairedResponse.status, 403);1587 assert.equal(pairedResponse.status, 403);
1356 assert.equal(pairedBody.error, "Only owner sessions can create pairing credentials.");1588 assert.equal(pairedBody._tag, "EnvironmentScopeRequiredError");
1589 assert.equal(pairedBody.code, "insufficient_scope");
1590 assert.equal(pairedBody.requiredScope, "access:write");
1591 assert.equal(typeof pairedBody.traceId, "string");
1357 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1592 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1358 );1593 );
13591594
1360 it.effect("lists paired clients and revokes other sessions while keeping the owner", () =>1595 it.effect("lists paired clients and revokes other sessions while keeping the administrator", () =>
1361 Effect.gen(function* () {1596 Effect.gen(function* () {
1362 yield* buildAppUnderTest({1597 yield* buildAppUnderTest({
1363 config: {1598 config: {
...@@ -1429,9 +1664,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1429,9 +1664,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1429 headers: {1664 headers: {
1430 cookie: pairedSessionCookieHeader,1665 cookie: pairedSessionCookieHeader,
1431 },1666 },
1667 body: yield* HttpBody.json({}),
1432 });1668 });
1433 const pairedClientPairingBody = (yield* pairedClientPairingResponse.json) as {1669 const pairedClientPairingBody = (yield* pairedClientPairingResponse.json) as {
1434 readonly error: string;1670 readonly _tag: string;
1671 readonly code: string;
1672 readonly reason: string;
1673 readonly traceId: string;
1435 };1674 };
14361675
1437 assert.equal(listBeforeResponse.status, 200);1676 assert.equal(listBeforeResponse.status, 200);
...@@ -1452,7 +1691,69 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1452,7 +1691,69 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1452 assert.lengthOf(clientsAfter, 1);1691 assert.lengthOf(clientsAfter, 1);
1453 assert.equal(clientsAfter[0]?.current, true);1692 assert.equal(clientsAfter[0]?.current, true);
1454 assert.equal(pairedClientPairingResponse.status, 401);1693 assert.equal(pairedClientPairingResponse.status, 401);
1455 assert.equal(pairedClientPairingBody.error, "Unauthorized request.");1694 assert.equal(pairedClientPairingBody._tag, "EnvironmentAuthInvalidError");
1695 assert.equal(pairedClientPairingBody.code, "auth_invalid");
1696 assert.equal(pairedClientPairingBody.reason, "invalid_credential");
1697 assert.equal(typeof pairedClientPairingBody.traceId, "string");
1698 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1699 );
1700
1701 it.effect("separates access inventory reads from credential management writes", () =>
1702 Effect.gen(function* () {
1703 yield* buildAppUnderTest({
1704 config: {
1705 host: "0.0.0.0",
1706 },
1707 });
1708
1709 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
1710 const issueScopedSession = Effect.fnUntraced(function* (
1711 scope: "access:read" | "access:write",
1712 ) {
1713 const pairingResponse = yield* HttpClient.post("/api/auth/pairing-token", {
1714 headers: {
1715 cookie: ownerCookie,
1716 },
1717 body: yield* HttpBody.json({ scopes: [scope] }),
1718 });
1719 assert.equal(pairingResponse.status, 200);
1720 const pairingBody = (yield* pairingResponse.json) as {
1721 readonly credential: string;
1722 };
1723 return yield* getAuthenticatedSessionCookieHeader(pairingBody.credential);
1724 });
1725
1726 const readCookie = yield* issueScopedSession("access:read");
1727 const readListResponse = yield* HttpClient.get("/api/auth/clients", {
1728 headers: {
1729 cookie: readCookie,
1730 },
1731 });
1732 const readWriteResponse = yield* HttpClient.post("/api/auth/pairing-token", {
1733 headers: {
1734 cookie: readCookie,
1735 },
1736 body: yield* HttpBody.json({}),
1737 });
1738 const readWriteBody = (yield* readWriteResponse.json) as {
1739 readonly requiredScope: string;
1740 };
1741
1742 const writeCookie = yield* issueScopedSession("access:write");
1743 const writeListResponse = yield* HttpClient.get("/api/auth/clients", {
1744 headers: {
1745 cookie: writeCookie,
1746 },
1747 });
1748 const writeListBody = (yield* writeListResponse.json) as {
1749 readonly requiredScope: string;
1750 };
1751
1752 assert.equal(readListResponse.status, 200);
1753 assert.equal(readWriteResponse.status, 403);
1754 assert.equal(readWriteBody.requiredScope, "access:write");
1755 assert.equal(writeListResponse.status, 403);
1756 assert.equal(writeListBody.requiredScope, "access:read");
1456 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1757 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1457 );1758 );
14581759
...@@ -1469,6 +1770,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1469,6 +1770,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1469 headers: {1770 headers: {
1470 cookie: ownerCookie,1771 cookie: ownerCookie,
1471 },1772 },
1773 body: yield* HttpBody.json({}),
1472 });1774 });
1473 const pairingBody = (yield* pairingResponse.json) as {1775 const pairingBody = (yield* pairingResponse.json) as {
1474 readonly credential: string;1776 readonly credential: string;
...@@ -1499,6 +1801,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1499,6 +1801,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1499 headers: {1801 headers: {
1500 cookie: pairedSessionCookie,1802 cookie: pairedSessionCookie,
1501 },1803 },
1804 body: yield* HttpBody.json({}),
1502 });1805 });
15031806
1504 assert.equal(revokeResponse.status, 200);1807 assert.equal(revokeResponse.status, 200);
...@@ -1515,10 +1818,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1515,10 +1818,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
15151818
1516 assert.equal(first.response.status, 200);1819 assert.equal(first.response.status, 200);
1517 assert.equal(second.response.status, 401);1820 assert.equal(second.response.status, 401);
1518 assert.equal(1821 assert.equal((second.body as { readonly _tag?: string })._tag, "EnvironmentAuthInvalidError");
1519 (second.body as { readonly error?: string }).error,1822 assert.equal((second.body as { readonly code?: string }).code, "auth_invalid");
1520 "Invalid bootstrap credential.",1823 assert.equal((second.body as { readonly reason?: string }).reason, "invalid_credential");
1521 );
1522 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1824 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1523 );1825 );
15241826
...@@ -1594,15 +1896,15 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1594,15 +1896,15 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1594 yield* buildAppUnderTest();1896 yield* buildAppUnderTest();
15951897
1596 const bearerToken = yield* getAuthenticatedBearerSessionToken();1898 const bearerToken = yield* getAuthenticatedBearerSessionToken();
1597 const wsTokenResponse = yield* HttpClient.post("/api/auth/ws-token", {1899 const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", {
1598 headers: {1900 headers: {
1599 authorization: `Bearer ${bearerToken}`,1901 authorization: `Bearer ${bearerToken}`,
1600 },1902 },
1601 });1903 });
1602 const wsTokenBody = (yield* wsTokenResponse.json) as {1904 const wsTicketBody = (yield* wsTicketResponse.json) as {
1603 readonly token: string;1905 readonly ticket: string;
1604 };1906 };
1605 const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsToken=${encodeURIComponent(wsTokenBody.token)}`;1907 const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`;
16061908
1607 const response = yield* Effect.scoped(1909 const response = yield* Effect.scoped(
1608 withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({})),1910 withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({})),
apps/server/src/server.ts+15-34
...@@ -1,13 +1,15 @@...@@ -1,13 +1,15 @@
1import { EnvironmentHttpApi } from "@t3tools/contracts";
1import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
2import * as Layer from "effect/Layer";3import * as Layer from "effect/Layer";
3import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http";4import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http";
5import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
46
5import { ServerConfig } from "./config.ts";7import { ServerConfig } from "./config.ts";
6import {8import {
7 attachmentsRouteLayer,9 attachmentsRouteLayer,
8 otlpTracesProxyRouteLayer,10 otlpTracesProxyRouteLayer,
9 projectFaviconRouteLayer,11 projectFaviconRouteLayer,
10 serverEnvironmentRouteLayer,12 serverEnvironmentHttpApiLayer,
11 staticAndDevRouteLayer,13 staticAndDevRouteLayer,
12 browserApiCorsLayer,14 browserApiCorsLayer,
13} from "./http.ts";15} from "./http.ts";
...@@ -62,20 +64,9 @@ import * as SourceControlRepositoryService from "./sourceControl/SourceControlRe...@@ -62,20 +64,9 @@ import * as SourceControlRepositoryService from "./sourceControl/SourceControlRe
62import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts";64import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts";
63import { ObservabilityLive } from "./observability/Layers/Observability.ts";65import { ObservabilityLive } from "./observability/Layers/Observability.ts";
64import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts";66import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts";
65import {67import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts";
66 authBearerBootstrapRouteLayer,68import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
67 authBootstrapRouteLayer,69import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
68 authClientsRevokeOthersRouteLayer,
69 authClientsRevokeRouteLayer,
70 authClientsRouteLayer,
71 authPairingLinksRevokeRouteLayer,
72 authPairingLinksRouteLayer,
73 authPairingCredentialRouteLayer,
74 authSessionRouteLayer,
75 authWebSocketTokenRouteLayer,
76} from "./auth/http.ts";
77import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";
78import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts";
79import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";70import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
80import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";71import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
81import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";72import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
...@@ -85,10 +76,7 @@ import {...@@ -85,10 +76,7 @@ import {
85 makePersistedServerRuntimeState,76 makePersistedServerRuntimeState,
86 persistServerRuntimeState,77 persistServerRuntimeState,
87} from "./serverRuntimeState.ts";78} from "./serverRuntimeState.ts";
88import {79import { orchestrationHttpApiLayer } from "./orchestration/http.ts";
89 orchestrationDispatchRouteLayer,
90 orchestrationSnapshotRouteLayer,
91} from "./orchestration/http.ts";
92import * as NetService from "@t3tools/shared/Net";80import * as NetService from "@t3tools/shared/Net";
93import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale";81import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale";
9482
...@@ -238,9 +226,9 @@ const WorkspaceLayerLive = Layer.mergeAll(...@@ -238,9 +226,9 @@ const WorkspaceLayerLive = Layer.mergeAll(
238 WorkspaceFileSystemLayerLive,226 WorkspaceFileSystemLayerLive,
239);227);
240228
241const AuthLayerLive = ServerAuthLive.pipe(229const AuthLayerLive = EnvironmentAuth.layer.pipe(
242 Layer.provideMerge(PersistenceLayerLive),230 Layer.provideMerge(PersistenceLayerLive),
243 Layer.provide(ServerSecretStoreLive),231 Layer.provide(ServerSecretStore.layer),
244);232);
245233
246const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe(234const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe(
...@@ -301,22 +289,15 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe(...@@ -301,22 +289,15 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe(
301);289);
302290
303export const makeRoutesLayer = Layer.mergeAll(291export const makeRoutesLayer = Layer.mergeAll(
304 authBearerBootstrapRouteLayer,292 HttpApiBuilder.layer(EnvironmentHttpApi).pipe(
305 authBootstrapRouteLayer,293 Layer.provide(authHttpApiLayer),
306 authClientsRevokeOthersRouteLayer,294 Layer.provide(orchestrationHttpApiLayer),
307 authClientsRevokeRouteLayer,295 Layer.provide(serverEnvironmentHttpApiLayer),
308 authClientsRouteLayer,296 Layer.provide(environmentAuthenticatedAuthLayer),
309 authPairingLinksRevokeRouteLayer,297 ),
310 authPairingLinksRouteLayer,
311 authPairingCredentialRouteLayer,
312 authSessionRouteLayer,
313 authWebSocketTokenRouteLayer,
314 attachmentsRouteLayer,298 attachmentsRouteLayer,
315 orchestrationDispatchRouteLayer,
316 orchestrationSnapshotRouteLayer,
317 otlpTracesProxyRouteLayer,299 otlpTracesProxyRouteLayer,
318 projectFaviconRouteLayer,300 projectFaviconRouteLayer,
319 serverEnvironmentRouteLayer,
320 staticAndDevRouteLayer,301 staticAndDevRouteLayer,
321 websocketRpcRouteLayer,302 websocketRpcRouteLayer,
322).pipe(Layer.provide(browserApiCorsLayer));303).pipe(Layer.provide(browserApiCorsLayer));
apps/server/src/serverRuntimeStartup.ts+2-2
...@@ -32,7 +32,7 @@ import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts";...@@ -32,7 +32,7 @@ import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts";
32import { ServerSettingsService } from "./serverSettings.ts";32import { ServerSettingsService } from "./serverSettings.ts";
33import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";33import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";
34import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";34import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";
35import { ServerAuth } from "./auth/Services/ServerAuth.ts";35import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
36import { ProviderSessionReaper } from "./provider/Services/ProviderSessionReaper.ts";36import { ProviderSessionReaper } from "./provider/Services/ProviderSessionReaper.ts";
37import {37import {
38 formatHeadlessServeOutput,38 formatHeadlessServeOutput,
...@@ -244,7 +244,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {...@@ -244,7 +244,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
244244
245const resolveStartupBrowserTarget = Effect.gen(function* () {245const resolveStartupBrowserTarget = Effect.gen(function* () {
246 const serverConfig = yield* ServerConfig;246 const serverConfig = yield* ServerConfig;
247 const serverAuth = yield* ServerAuth;247 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
248 const localUrl = `http://localhost:${serverConfig.port}`;248 const localUrl = `http://localhost:${serverConfig.port}`;
249 const bindUrl =249 const bindUrl =
250 serverConfig.host && !isWildcardHost(serverConfig.host)250 serverConfig.host && !isWildcardHost(serverConfig.host)
apps/server/src/serverSettings.ts+3-4
...@@ -47,8 +47,7 @@ import { ServerConfig } from "./config.ts";...@@ -47,8 +47,7 @@ import { ServerConfig } from "./config.ts";
47import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";47import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";
48import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";48import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";
49import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";49import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
50import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";50import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
51import { ServerSecretStore } from "./auth/Services/ServerSecretStore.ts";
5251
53const encodeServerSettings = Schema.encodeEffect(ServerSettings);52const encodeServerSettings = Schema.encodeEffect(ServerSettings);
54const encodeServerSettingsJson = Schema.encodeUnknownEffect(fromJsonStringPretty(ServerSettings));53const encodeServerSettingsJson = Schema.encodeUnknownEffect(fromJsonStringPretty(ServerSettings));
...@@ -257,7 +256,7 @@ const makeServerSettings = Effect.gen(function* () {...@@ -257,7 +256,7 @@ const makeServerSettings = Effect.gen(function* () {
257 const { settingsPath } = yield* ServerConfig;256 const { settingsPath } = yield* ServerConfig;
258 const fs = yield* FileSystem.FileSystem;257 const fs = yield* FileSystem.FileSystem;
259 const pathService = yield* Path.Path;258 const pathService = yield* Path.Path;
260 const secretStore = yield* ServerSecretStore;259 const secretStore = yield* ServerSecretStore.ServerSecretStore;
261 const writeSemaphore = yield* Semaphore.make(1);260 const writeSemaphore = yield* Semaphore.make(1);
262 const cacheKey = "settings" as const;261 const cacheKey = "settings" as const;
263 const changesPubSub = yield* PubSub.unbounded<ServerSettings>();262 const changesPubSub = yield* PubSub.unbounded<ServerSettings>();
...@@ -580,5 +579,5 @@ const makeServerSettings = Effect.gen(function* () {...@@ -580,5 +579,5 @@ const makeServerSettings = Effect.gen(function* () {
580});579});
581580
582export const ServerSettingsLive = Layer.effect(ServerSettingsService, makeServerSettings).pipe(581export const ServerSettingsLive = Layer.effect(ServerSettingsService, makeServerSettings).pipe(
583 Layer.provide(ServerSecretStoreLive),582 Layer.provide(ServerSecretStore.layer),
584);583);
apps/server/src/startupAccess.ts+3-3
...@@ -5,7 +5,7 @@ import * as Effect from "effect/Effect";...@@ -5,7 +5,7 @@ import * as Effect from "effect/Effect";
5import { HttpServer } from "effect/unstable/http";5import { HttpServer } from "effect/unstable/http";
66
7import { ServerConfig } from "./config.ts";7import { ServerConfig } from "./config.ts";
8import { ServerAuth } from "./auth/Services/ServerAuth.ts";8import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
99
10export interface HeadlessServeAccessInfo {10export interface HeadlessServeAccessInfo {
11 readonly connectionString: string;11 readonly connectionString: string;
...@@ -133,12 +133,12 @@ export const formatHeadlessServeOutput = (accessInfo: HeadlessServeAccessInfo):...@@ -133,12 +133,12 @@ export const formatHeadlessServeOutput = (accessInfo: HeadlessServeAccessInfo):
133export const issueHeadlessServeAccessInfo = Effect.fn("issueHeadlessServeAccessInfo")(function* () {133export const issueHeadlessServeAccessInfo = Effect.fn("issueHeadlessServeAccessInfo")(function* () {
134 const serverConfig = yield* ServerConfig;134 const serverConfig = yield* ServerConfig;
135 const httpServer = yield* HttpServer.HttpServer;135 const httpServer = yield* HttpServer.HttpServer;
136 const serverAuth = yield* ServerAuth;136 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
137 const connectionString = resolveHeadlessConnectionString(137 const connectionString = resolveHeadlessConnectionString(
138 serverConfig.host,138 serverConfig.host,
139 resolveListeningPort(httpServer.address, serverConfig.port),139 resolveListeningPort(httpServer.address, serverConfig.port),
140 );140 );
141 const issued = yield* serverAuth.issuePairingCredential({ role: "owner" });141 const issued = yield* serverAuth.issueStartupPairingCredential();
142142
143 return {143 return {
144 connectionString,144 connectionString,
apps/server/src/ws.ts+163-28
...@@ -11,7 +11,14 @@ import * as Schema from "effect/Schema";...@@ -11,7 +11,14 @@ import * as Schema from "effect/Schema";
11import * as Stream from "effect/Stream";11import * as Stream from "effect/Stream";
12import {12import {
13 DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL,13 DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL,
14 AuthOrchestrationOperateScope,
15 AuthOrchestrationReadScope,
16 AuthReviewWriteScope,
17 AuthTerminalOperateScope,
18 AuthAccessReadScope,
19 AuthAccessStreamError,
14 type AuthAccessStreamEvent,20 type AuthAccessStreamEvent,
21 type AuthEnvironmentScope,
15 AuthSessionId,22 AuthSessionId,
16 CommandId,23 CommandId,
17 EventId,24 EventId,
...@@ -29,6 +36,7 @@ import {...@@ -29,6 +36,7 @@ import {
29 ProjectWriteFileError,36 ProjectWriteFileError,
30 OrchestrationReplayEventsError,37 OrchestrationReplayEventsError,
31 FilesystemBrowseError,38 FilesystemBrowseError,
39 EnvironmentAuthorizationError,
32 ThreadId,40 ThreadId,
33 type TerminalAttachStreamEvent,41 type TerminalAttachStreamEvent,
34 type TerminalError,42 type TerminalError,
...@@ -38,7 +46,7 @@ import {...@@ -38,7 +46,7 @@ import {
38 WsRpcGroup,46 WsRpcGroup,
39} from "@t3tools/contracts";47} from "@t3tools/contracts";
40import { clamp } from "effect/Number";48import { clamp } from "effect/Number";
41import { HttpRouter, HttpServerRequest } from "effect/unstable/http";49import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http";
42import { RpcSerialization, RpcServer } from "effect/unstable/rpc";50import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
4351
44import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts";52import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts";
...@@ -49,9 +57,9 @@ import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts";...@@ -49,9 +57,9 @@ import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts";
49import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts";57import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts";
50import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";58import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";
51import {59import {
52 observeRpcEffect,60 observeRpcEffect as instrumentRpcEffect,
53 observeRpcStream,61 observeRpcStream as instrumentRpcStream,
54 observeRpcStreamEffect,62 observeRpcStreamEffect as instrumentRpcStreamEffect,
55} from "./observability/RpcInstrumentation.ts";63} from "./observability/RpcInstrumentation.ts";
56import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts";64import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts";
57import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";65import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";
...@@ -69,7 +77,8 @@ import { ReviewService } from "./review/ReviewService.ts";...@@ -69,7 +77,8 @@ import { ReviewService } from "./review/ReviewService.ts";
69import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts";77import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts";
70import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver.ts";78import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver.ts";
71import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";79import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";
72import { ServerAuth } from "./auth/Services/ServerAuth.ts";80import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
81import type { AuthenticatedSession } from "./auth/EnvironmentAuth.ts";
73import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";82import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
74import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";83import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
75import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";84import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
...@@ -84,15 +93,9 @@ import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";...@@ -84,15 +93,9 @@ import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
84import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";93import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";
85import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts";94import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts";
86import * as VcsProcess from "./vcs/VcsProcess.ts";95import * as VcsProcess from "./vcs/VcsProcess.ts";
87import {96import * as PairingGrantStore from "./auth/PairingGrantStore.ts";
88 BootstrapCredentialService,97import * as SessionStore from "./auth/SessionStore.ts";
89 type BootstrapCredentialChange,98import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts";
90} from "./auth/Services/BootstrapCredentialService.ts";
91import {
92 SessionCredentialService,
93 type SessionCredentialChange,
94} from "./auth/Services/SessionCredentialService.ts";
95import { respondToAuthError } from "./auth/http.ts";
96const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError);99const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError);
97const isWorkspacePathOutsideRootError = Schema.is(WorkspacePathOutsideRootError);100const isWorkspacePathOutsideRootError = Schema.is(WorkspacePathOutsideRootError);
98101
...@@ -122,8 +125,62 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract<...@@ -122,8 +125,62 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract<
122125
123const PROVIDER_STATUS_DEBOUNCE_MS = 200;126const PROVIDER_STATUS_DEBOUNCE_MS = 200;
124127
128const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([
129 [ORCHESTRATION_WS_METHODS.dispatchCommand, AuthOrchestrationOperateScope],
130 [ORCHESTRATION_WS_METHODS.getTurnDiff, AuthOrchestrationReadScope],
131 [ORCHESTRATION_WS_METHODS.getFullThreadDiff, AuthOrchestrationReadScope],
132 [ORCHESTRATION_WS_METHODS.replayEvents, AuthOrchestrationReadScope],
133 [ORCHESTRATION_WS_METHODS.subscribeShell, AuthOrchestrationReadScope],
134 [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, AuthOrchestrationReadScope],
135 [ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope],
136 [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope],
137 [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope],
138 [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope],
139 [WS_METHODS.serverUpsertKeybinding, AuthOrchestrationOperateScope],
140 [WS_METHODS.serverRemoveKeybinding, AuthOrchestrationOperateScope],
141 [WS_METHODS.serverGetSettings, AuthOrchestrationReadScope],
142 [WS_METHODS.serverUpdateSettings, AuthOrchestrationOperateScope],
143 [WS_METHODS.serverDiscoverSourceControl, AuthOrchestrationReadScope],
144 [WS_METHODS.serverGetTraceDiagnostics, AuthOrchestrationReadScope],
145 [WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope],
146 [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope],
147 [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope],
148 [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope],
149 [WS_METHODS.sourceControlCloneRepository, AuthOrchestrationOperateScope],
150 [WS_METHODS.sourceControlPublishRepository, AuthOrchestrationOperateScope],
151 [WS_METHODS.projectsSearchEntries, AuthOrchestrationReadScope],
152 [WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope],
153 [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope],
154 [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope],
155 [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope],
156 [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope],
157 [WS_METHODS.vcsPull, AuthOrchestrationOperateScope],
158 [WS_METHODS.gitRunStackedAction, AuthOrchestrationOperateScope],
159 [WS_METHODS.gitResolvePullRequest, AuthOrchestrationOperateScope],
160 [WS_METHODS.gitPreparePullRequestThread, AuthOrchestrationOperateScope],
161 [WS_METHODS.vcsListRefs, AuthOrchestrationReadScope],
162 [WS_METHODS.vcsCreateWorktree, AuthOrchestrationOperateScope],
163 [WS_METHODS.vcsRemoveWorktree, AuthOrchestrationOperateScope],
164 [WS_METHODS.vcsCreateRef, AuthOrchestrationOperateScope],
165 [WS_METHODS.vcsSwitchRef, AuthOrchestrationOperateScope],
166 [WS_METHODS.vcsInit, AuthOrchestrationOperateScope],
167 [WS_METHODS.reviewGetDiffPreview, AuthReviewWriteScope],
168 [WS_METHODS.terminalOpen, AuthTerminalOperateScope],
169 [WS_METHODS.terminalAttach, AuthTerminalOperateScope],
170 [WS_METHODS.terminalWrite, AuthTerminalOperateScope],
171 [WS_METHODS.terminalResize, AuthTerminalOperateScope],
172 [WS_METHODS.terminalClear, AuthTerminalOperateScope],
173 [WS_METHODS.terminalRestart, AuthTerminalOperateScope],
174 [WS_METHODS.terminalClose, AuthTerminalOperateScope],
175 [WS_METHODS.subscribeTerminalEvents, AuthTerminalOperateScope],
176 [WS_METHODS.subscribeTerminalMetadata, AuthTerminalOperateScope],
177 [WS_METHODS.subscribeServerConfig, AuthOrchestrationReadScope],
178 [WS_METHODS.subscribeServerLifecycle, AuthOrchestrationReadScope],
179 [WS_METHODS.subscribeAuthAccess, AuthAccessReadScope],
180]);
181
125function toAuthAccessStreamEvent(182function toAuthAccessStreamEvent(
126 change: BootstrapCredentialChange | SessionCredentialChange,183 change: PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange,
127 revision: number,184 revision: number,
128 currentSessionId: AuthSessionId,185 currentSessionId: AuthSessionId,
129): AuthAccessStreamEvent {186): AuthAccessStreamEvent {
...@@ -162,9 +219,10 @@ function toAuthAccessStreamEvent(...@@ -162,9 +219,10 @@ function toAuthAccessStreamEvent(
162 }219 }
163}220}
164221
165const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>222const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>
166 WsRpcGroup.toLayer(223 WsRpcGroup.toLayer(
167 Effect.gen(function* () {224 Effect.gen(function* () {
225 const currentSessionId = currentSession.sessionId;
168 const crypto = yield* Crypto.Crypto;226 const crypto = yield* Crypto.Crypto;
169 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;227 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
170 const orchestrationEngine = yield* OrchestrationEngineService;228 const orchestrationEngine = yield* OrchestrationEngineService;
...@@ -187,7 +245,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>...@@ -187,7 +245,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
187 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner;245 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner;
188 const repositoryIdentityResolver = yield* RepositoryIdentityResolver;246 const repositoryIdentityResolver = yield* RepositoryIdentityResolver;
189 const serverEnvironment = yield* ServerEnvironment;247 const serverEnvironment = yield* ServerEnvironment;
190 const serverAuth = yield* ServerAuth;248 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
191 const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery;249 const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery;
192 const automaticGitFetchInterval = serverSettings.getSettings.pipe(250 const automaticGitFetchInterval = serverSettings.getSettings.pipe(
193 Effect.map((settings) => settings.automaticGitFetchInterval),251 Effect.map((settings) => settings.automaticGitFetchInterval),
...@@ -198,10 +256,70 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>...@@ -198,10 +256,70 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
198 ),256 ),
199 );257 );
200 const sourceControlRepositories = yield* SourceControlRepositoryService;258 const sourceControlRepositories = yield* SourceControlRepositoryService;
201 const bootstrapCredentials = yield* BootstrapCredentialService;259 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
202 const sessions = yield* SessionCredentialService;260 const sessions = yield* SessionStore.SessionStore;
203 const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics;261 const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics;
204 const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor;262 const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor;
263 const authorizationError = (requiredScope: AuthEnvironmentScope) =>
264 new EnvironmentAuthorizationError({
265 message: `The authenticated token is missing required scope: ${requiredScope}.`,
266 requiredScope,
267 });
268 const authorizeEffect = <A, E, R>(
269 requiredScope: AuthEnvironmentScope,
270 effect: Effect.Effect<A, E, R>,
271 ): Effect.Effect<A, E | EnvironmentAuthorizationError, R> =>
272 currentSession.scopes.includes(requiredScope)
273 ? effect
274 : Effect.fail(authorizationError(requiredScope));
275 const authorizeStream = <A, E, R>(
276 requiredScope: AuthEnvironmentScope,
277 stream: Stream.Stream<A, E, R>,
278 ): Stream.Stream<A, E | EnvironmentAuthorizationError, R> =>
279 currentSession.scopes.includes(requiredScope)
280 ? stream
281 : Stream.fail(authorizationError(requiredScope));
282 const requiredScopeForMethod = (method: string): AuthEnvironmentScope => {
283 const requiredScope = RPC_REQUIRED_SCOPE.get(method);
284 if (requiredScope === undefined) {
285 throw new Error(`RPC method ${method} has no declared authorization scope.`);
286 }
287 return requiredScope;
288 };
289 const observeRpcEffect = <A, E, R>(
290 method: string,
291 effect: Effect.Effect<A, E, R>,
292 traceAttributes?: Readonly<Record<string, unknown>>,
293 ) =>
294 instrumentRpcEffect(
295 method,
296 authorizeEffect(requiredScopeForMethod(method), effect),
297 traceAttributes,
298 );
299 const observeRpcStream = <A, E, R>(
300 method: string,
301 stream: Stream.Stream<A, E, R>,
302 traceAttributes?: Readonly<Record<string, unknown>>,
303 ) =>
304 instrumentRpcStream(
305 method,
306 authorizeStream(requiredScopeForMethod(method), stream),
307 traceAttributes,
308 );
309 const observeRpcStreamEffect = <A, StreamError, StreamContext, EffectError, EffectContext>(
310 method: string,
311 effect: Effect.Effect<
312 Stream.Stream<A, StreamError, StreamContext>,
313 EffectError,
314 EffectContext
315 >,
316 traceAttributes?: Readonly<Record<string, unknown>>,
317 ) =>
318 instrumentRpcStreamEffect(
319 method,
320 authorizeEffect(requiredScopeForMethod(method), effect),
321 traceAttributes,
322 );
205 const toDispatchCommandError = (cause: unknown, fallbackMessage: string) =>323 const toDispatchCommandError = (cause: unknown, fallbackMessage: string) =>
206 isOrchestrationDispatchCommandError(cause)324 isOrchestrationDispatchCommandError(cause)
207 ? cause325 ? cause
...@@ -220,9 +338,16 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>...@@ -220,9 +338,16 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
220338
221 const loadAuthAccessSnapshot = () =>339 const loadAuthAccessSnapshot = () =>
222 Effect.all({340 Effect.all({
223 pairingLinks: serverAuth.listPairingLinks().pipe(Effect.orDie),341 pairingLinks: serverAuth.listPairingLinks(),
224 clientSessions: serverAuth.listClientSessions(currentSessionId).pipe(Effect.orDie),342 clientSessions: serverAuth.listClientSessions(currentSessionId),
225 });343 }).pipe(
344 Effect.mapError(
345 (error) =>
346 new AuthAccessStreamError({
347 message: error.message,
348 }),
349 ),
350 );
226351
227 const appendSetupScriptActivity = (input: {352 const appendSetupScriptActivity = (input: {
228 readonly threadId: ThreadId;353 readonly threadId: ThreadId;
...@@ -1258,7 +1383,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>...@@ -1258,7 +1383,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
1258 const initialSnapshot = yield* loadAuthAccessSnapshot();1383 const initialSnapshot = yield* loadAuthAccessSnapshot();
1259 const revisionRef = yield* Ref.make(1);1384 const revisionRef = yield* Ref.make(1);
1260 const accessChanges: Stream.Stream<1385 const accessChanges: Stream.Stream<
1261 BootstrapCredentialChange | SessionCredentialChange1386 PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange
1262 > = Stream.merge(bootstrapCredentials.streamChanges, sessions.streamChanges);1387 > = Stream.merge(bootstrapCredentials.streamChanges, sessions.streamChanges);
12631388
1264 const liveEvents: Stream.Stream<AuthAccessStreamEvent> = accessChanges.pipe(1389 const liveEvents: Stream.Stream<AuthAccessStreamEvent> = accessChanges.pipe(
...@@ -1294,14 +1419,19 @@ export const websocketRpcRouteLayer = Layer.unwrap(...@@ -1294,14 +1419,19 @@ export const websocketRpcRouteLayer = Layer.unwrap(
1294 "/ws",1419 "/ws",
1295 Effect.gen(function* () {1420 Effect.gen(function* () {
1296 const request = yield* HttpServerRequest.HttpServerRequest;1421 const request = yield* HttpServerRequest.HttpServerRequest;
1297 const serverAuth = yield* ServerAuth;1422 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
1298 const sessions = yield* SessionCredentialService;1423 const sessions = yield* SessionStore.SessionStore;
1299 const session = yield* serverAuth.authenticateWebSocketUpgrade(request);1424 const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe(
1425 Effect.catchTags({
1426 ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason),
1427 ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error),
1428 }),
1429 );
1300 const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, {1430 const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, {
1301 disableTracing: true,1431 disableTracing: true,
1302 }).pipe(1432 }).pipe(
1303 Effect.provide(1433 Effect.provide(
1304 makeWsRpcLayer(session.sessionId).pipe(1434 makeWsRpcLayer(session).pipe(
1305 Layer.provideMerge(RpcSerialization.layerJson),1435 Layer.provideMerge(RpcSerialization.layerJson),
1306 Layer.provide(ProviderMaintenanceRunner.layer),1436 Layer.provide(ProviderMaintenanceRunner.layer),
1307 Layer.provide(1437 Layer.provide(
...@@ -1333,7 +1463,12 @@ export const websocketRpcRouteLayer = Layer.unwrap(...@@ -1333,7 +1463,12 @@ export const websocketRpcRouteLayer = Layer.unwrap(
1333 () => rpcWebSocketHttpEffect,1463 () => rpcWebSocketHttpEffect,
1334 () => sessions.markDisconnected(session.sessionId),1464 () => sessions.markDisconnected(session.sessionId),
1335 );1465 );
1336 }).pipe(Effect.catchTag("AuthError", respondToAuthError)),1466 }).pipe(
1467 Effect.catchTags({
1468 EnvironmentAuthInvalidError: HttpServerRespondable.toResponse,
1469 EnvironmentInternalError: HttpServerRespondable.toResponse,
1470 }),
1471 ),
1337 ),1472 ),
1338 ),1473 ),
1339);1474);
apps/web/package.json+2-1
...@@ -43,6 +43,7 @@...@@ -43,6 +43,7 @@
43 "zustand": "^5.0.11"43 "zustand": "^5.0.11"
44 },44 },
45 "devDependencies": {45 "devDependencies": {
46 "@effect/platform-node": "catalog:",
46 "@rolldown/plugin-babel": "^0.2.0",47 "@rolldown/plugin-babel": "^0.2.0",
47 "@tailwindcss/vite": "^4.0.0",48 "@tailwindcss/vite": "^4.0.0",
48 "@tanstack/router-plugin": "^1.161.0",49 "@tanstack/router-plugin": "^1.161.0",
...@@ -51,7 +52,7 @@...@@ -51,7 +52,7 @@
51 "@types/react-dom": "~19.2.3",52 "@types/react-dom": "~19.2.3",
52 "@vercel/config": "^0.3.0",53 "@vercel/config": "^0.3.0",
53 "@vitejs/plugin-react": "^6.0.0",54 "@vitejs/plugin-react": "^6.0.0",
54 "@vitest/browser-playwright": "^4.0.18",55 "@vitest/browser-playwright": "catalog:",
55 "babel-plugin-react-compiler": "1.0.0",56 "babel-plugin-react-compiler": "1.0.0",
56 "msw": "2.12.11",57 "msw": "2.12.11",
57 "playwright": "^1.58.2",58 "playwright": "^1.58.2",
apps/web/src/authBootstrap.test.ts+174-316
...@@ -1,15 +1,17 @@...@@ -1,15 +1,17 @@
1import type { DesktopBridge } from "@t3tools/contracts";1import {
2 AuthSessionState as AuthSessionStateSchema,
3 EnvironmentAuthInvalidError,
4 type AuthBrowserSessionResult,
5 type AuthCreatePairingCredentialInput,
6 type AuthSessionState,
7 type DesktopBridge,
8} from "@t3tools/contracts";
9import * as DateTime from "effect/DateTime";
10import * as Effect from "effect/Effect";
11import * as Schema from "effect/Schema";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";12import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
313
4function jsonResponse(body: unknown, init?: ResponseInit) {14import { installEnvironmentHttpTest } from "../test/environmentHttpTest";
5 return new Response(JSON.stringify(body), {
6 headers: {
7 "content-type": "application/json",
8 },
9 status: 200,
10 ...init,
11 });
12}
1315
14type TestWindow = {16type TestWindow = {
15 location: URL;17 location: URL;
...@@ -19,6 +21,42 @@ type TestWindow = {...@@ -19,6 +21,42 @@ type TestWindow = {
19 desktopBridge?: DesktopBridge;21 desktopBridge?: DesktopBridge;
20};22};
2123
24const LOOPBACK_AUTH = {
25 policy: "loopback-browser",
26 bootstrapMethods: ["one-time-token"],
27 sessionMethods: ["browser-session-cookie"],
28 sessionCookieName: "t3_session",
29} as const;
30
31const DESKTOP_AUTH = {
32 policy: "desktop-managed-local",
33 bootstrapMethods: ["desktop-bootstrap"],
34 sessionMethods: ["browser-session-cookie"],
35 sessionCookieName: "t3_session",
36} as const;
37
38const SESSION_EXPIRES_AT = DateTime.makeUnsafe("2026-04-05T00:00:00.000Z");
39const encodeAuthSessionState = Schema.encodeSync(AuthSessionStateSchema);
40
41const unauthenticatedSession = (auth: AuthSessionState["auth"]): AuthSessionState => ({
42 authenticated: false,
43 auth,
44});
45
46const authenticatedSession = (auth: AuthSessionState["auth"]): AuthSessionState => ({
47 authenticated: true,
48 auth,
49 sessionMethod: "browser-session-cookie",
50 expiresAt: SESSION_EXPIRES_AT,
51});
52
53const browserSession = (scopes: AuthBrowserSessionResult["scopes"]): AuthBrowserSessionResult => ({
54 authenticated: true,
55 scopes,
56 sessionMethod: "browser-session-cookie",
57 expiresAt: SESSION_EXPIRES_AT,
58});
59
22function installTestBrowser(url: string) {60function installTestBrowser(url: string) {
23 const testWindow: TestWindow = {61 const testWindow: TestWindow = {
24 location: new URL(url),62 location: new URL(url),
...@@ -35,8 +73,36 @@ function installTestBrowser(url: string) {...@@ -35,8 +73,36 @@ function installTestBrowser(url: string) {
35 return testWindow;73 return testWindow;
36}74}
3775
38function sessionResponse(body: unknown, init?: ResponseInit) {76function sequence<A>(...values: ReadonlyArray<A>) {
39 return jsonResponse(body, init);77 let index = 0;
78 return () => values[Math.min(index++, values.length - 1)]!;
79}
80
81let disposeHttpTest: (() => Promise<void>) | undefined;
82
83async function installAuthApi(input: {
84 readonly session?: () => AuthSessionState;
85 readonly browserSession?: (
86 credential: string,
87 ) => Effect.Effect<AuthBrowserSessionResult, EnvironmentAuthInvalidError>;
88 readonly pairingCredential?: (payload: AuthCreatePairingCredentialInput) => Effect.Effect<{
89 readonly id: string;
90 readonly credential: string;
91 readonly label?: string;
92 readonly expiresAt: DateTime.Utc;
93 }>;
94}) {
95 const testApi = await installEnvironmentHttpTest({
96 ...(input.session ? { session: () => Effect.succeed(input.session!()) } : {}),
97 ...(input.browserSession
98 ? { browserSession: (payload) => input.browserSession!(payload.credential) }
99 : {}),
100 ...(input.pairingCredential
101 ? { pairingCredential: (payload) => input.pairingCredential!(payload) }
102 : {}),
103 });
104 disposeHttpTest = testApi.dispose;
105 return testApi;
40}106}
41107
42describe("resolveInitialServerAuthGateState", () => {108describe("resolveInitialServerAuthGateState", () => {
...@@ -47,6 +113,8 @@ describe("resolveInitialServerAuthGateState", () => {...@@ -47,6 +113,8 @@ describe("resolveInitialServerAuthGateState", () => {
47 });113 });
48114
49 afterEach(async () => {115 afterEach(async () => {
116 await disposeHttpTest?.();
117 disposeHttpTest = undefined;
50 const { __resetServerAuthBootstrapForTests } = await import("./environments/primary");118 const { __resetServerAuthBootstrapForTests } = await import("./environments/primary");
51 __resetServerAuthBootstrapForTests();119 __resetServerAuthBootstrapForTests();
52 vi.unstubAllEnvs();120 vi.unstubAllEnvs();
...@@ -55,40 +123,14 @@ describe("resolveInitialServerAuthGateState", () => {...@@ -55,40 +123,14 @@ describe("resolveInitialServerAuthGateState", () => {
55 });123 });
56124
57 it("reuses an in-flight silent bootstrap attempt", async () => {125 it("reuses an in-flight silent bootstrap attempt", async () => {
58 const fetchMock = vi126 const nextSession = sequence(
59 .fn<typeof fetch>()127 unauthenticatedSession(DESKTOP_AUTH),
60 .mockResolvedValueOnce(128 authenticatedSession(DESKTOP_AUTH),
61 sessionResponse({129 );
62 authenticated: false,130 const testApi = await installAuthApi({
63 auth: {131 session: nextSession,
64 policy: "desktop-managed-local",132 browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])),
65 bootstrapMethods: ["desktop-bootstrap"],133 });
66 sessionMethods: ["browser-session-cookie"],
67 sessionCookieName: "t3_session",
68 },
69 }),
70 )
71 .mockResolvedValueOnce(
72 jsonResponse({
73 authenticated: true,
74 sessionMethod: "browser-session-cookie",
75 expiresAt: "2026-04-05T00:00:00.000Z",
76 }),
77 )
78 .mockResolvedValueOnce(
79 sessionResponse({
80 authenticated: true,
81 auth: {
82 policy: "loopback-browser",
83 bootstrapMethods: ["one-time-token"],
84 sessionMethods: ["browser-session-cookie"],
85 sessionCookieName: "t3_session",
86 },
87 sessionMethod: "browser-session-cookie",
88 expiresAt: "2026-04-05T00:00:00.000Z",
89 }),
90 );
91 vi.stubGlobal("fetch", fetchMock);
92134
93 const testWindow = installTestBrowser("http://localhost/");135 const testWindow = installTestBrowser("http://localhost/");
94 testWindow.desktopBridge = {136 testWindow.desktopBridge = {
...@@ -104,90 +146,45 @@ describe("resolveInitialServerAuthGateState", () => {...@@ -104,90 +146,45 @@ describe("resolveInitialServerAuthGateState", () => {
104146
105 await Promise.all([resolveInitialServerAuthGateState(), resolveInitialServerAuthGateState()]);147 await Promise.all([resolveInitialServerAuthGateState(), resolveInitialServerAuthGateState()]);
106148
107 expect(fetchMock).toHaveBeenCalledTimes(3);149 expect(testApi.calls.session).toBe(2);
108 expect(fetchMock.mock.calls[0]?.[0]).toBe("http://localhost:3773/api/auth/session");150 expect(testApi.calls.browserSession).toEqual([{ credential: "desktop-bootstrap-token" }]);
109 expect(fetchMock.mock.calls[1]?.[0]).toBe("http://localhost:3773/api/auth/bootstrap");
110 expect(fetchMock.mock.calls[2]?.[0]).toBe("http://localhost:3773/api/auth/session");
111 });151 });
112152
113 it("uses https fetch urls when the primary environment uses wss", async () => {153 it("uses https urls when the primary environment uses wss", async () => {
114 const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce(154 await installAuthApi({ session: () => unauthenticatedSession(LOOPBACK_AUTH) });
115 sessionResponse({
116 authenticated: false,
117 auth: {
118 policy: "loopback-browser",
119 bootstrapMethods: ["one-time-token"],
120 sessionMethods: ["browser-session-cookie"],
121 sessionCookieName: "t3_session",
122 },
123 }),
124 );
125 vi.stubGlobal("fetch", fetchMock);
126 vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com");155 vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com");
127 vi.stubEnv("VITE_WS_URL", "wss://remote.example.com");156 vi.stubEnv("VITE_WS_URL", "wss://remote.example.com");
128157
129 const { resolveInitialServerAuthGateState } = await import("./environments/primary");158 const { resolveInitialServerAuthGateState, resolvePrimaryEnvironmentHttpUrl } =
159 await import("./environments/primary");
130160
131 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({161 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
132 status: "requires-auth",162 status: "requires-auth",
133 auth: {163 auth: LOOPBACK_AUTH,
134 policy: "loopback-browser",
135 bootstrapMethods: ["one-time-token"],
136 sessionMethods: ["browser-session-cookie"],
137 sessionCookieName: "t3_session",
138 },
139 });
140
141 expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/api/auth/session", {
142 credentials: "include",
143 });164 });
165 expect(resolvePrimaryEnvironmentHttpUrl("/api/auth/session")).toBe(
166 "https://remote.example.com/api/auth/session",
167 );
144 });168 });
145169
146 it("uses the current origin as an auth proxy base for local dev environments", async () => {170 it("uses the current origin as an auth proxy base for local dev environments", async () => {
147 const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce(171 await installAuthApi({ session: () => unauthenticatedSession(LOOPBACK_AUTH) });
148 sessionResponse({
149 authenticated: false,
150 auth: {
151 policy: "loopback-browser",
152 bootstrapMethods: ["one-time-token"],
153 sessionMethods: ["browser-session-cookie"],
154 sessionCookieName: "t3_session",
155 },
156 }),
157 );
158 vi.stubGlobal("fetch", fetchMock);
159 installTestBrowser("http://localhost:5735/");172 installTestBrowser("http://localhost:5735/");
160173
161 const { resolveInitialServerAuthGateState } = await import("./environments/primary");174 const { resolveInitialServerAuthGateState, resolvePrimaryEnvironmentHttpUrl } =
175 await import("./environments/primary");
162176
163 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({177 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
164 status: "requires-auth",178 status: "requires-auth",
165 auth: {179 auth: LOOPBACK_AUTH,
166 policy: "loopback-browser",
167 bootstrapMethods: ["one-time-token"],
168 sessionMethods: ["browser-session-cookie"],
169 sessionCookieName: "t3_session",
170 },
171 });
172
173 expect(fetchMock).toHaveBeenCalledWith("http://localhost:5735/api/auth/session", {
174 credentials: "include",
175 });180 });
181 expect(resolvePrimaryEnvironmentHttpUrl("/api/auth/session")).toBe(
182 "http://localhost:5735/api/auth/session",
183 );
176 });184 });
177185
178 it("uses the vite proxy for desktop-managed loopback auth requests during local dev", async () => {186 it("uses the vite proxy for desktop-managed loopback auth requests during local dev", async () => {
179 const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce(187 await installAuthApi({ session: () => unauthenticatedSession(DESKTOP_AUTH) });
180 sessionResponse({
181 authenticated: false,
182 auth: {
183 policy: "desktop-managed-local",
184 bootstrapMethods: ["desktop-bootstrap"],
185 sessionMethods: ["browser-session-cookie"],
186 sessionCookieName: "t3_session",
187 },
188 }),
189 );
190 vi.stubGlobal("fetch", fetchMock);
191 vi.stubEnv("VITE_DEV_SERVER_URL", "http://127.0.0.1:5733");188 vi.stubEnv("VITE_DEV_SERVER_URL", "http://127.0.0.1:5733");
192189
193 const testWindow = installTestBrowser("http://127.0.0.1:5733/");190 const testWindow = installTestBrowser("http://127.0.0.1:5733/");
...@@ -199,47 +196,25 @@ describe("resolveInitialServerAuthGateState", () => {...@@ -199,47 +196,25 @@ describe("resolveInitialServerAuthGateState", () => {
199 }),196 }),
200 } as DesktopBridge;197 } as DesktopBridge;
201198
202 const { resolveInitialServerAuthGateState } = await import("./environments/primary");199 const { resolveInitialServerAuthGateState, resolvePrimaryEnvironmentHttpUrl } =
200 await import("./environments/primary");
203201
204 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({202 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
205 status: "requires-auth",203 status: "requires-auth",
206 auth: {204 auth: DESKTOP_AUTH,
207 policy: "desktop-managed-local",
208 bootstrapMethods: ["desktop-bootstrap"],
209 sessionMethods: ["browser-session-cookie"],
210 sessionCookieName: "t3_session",
211 },
212 });
213
214 expect(fetchMock).toHaveBeenCalledWith("http://127.0.0.1:5733/api/auth/session", {
215 credentials: "include",
216 });205 });
206 expect(resolvePrimaryEnvironmentHttpUrl("/api/auth/session")).toBe(
207 "http://127.0.0.1:5733/api/auth/session",
208 );
217 });209 });
218210
219 it("returns a requires-auth state instead of throwing when no bootstrap credential exists", async () => {211 it("returns a requires-auth state instead of throwing when no bootstrap credential exists", async () => {
220 const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce(212 await installAuthApi({ session: () => unauthenticatedSession(LOOPBACK_AUTH) });
221 sessionResponse({
222 authenticated: false,
223 auth: {
224 policy: "loopback-browser",
225 bootstrapMethods: ["one-time-token"],
226 sessionMethods: ["browser-session-cookie"],
227 sessionCookieName: "t3_session",
228 },
229 }),
230 );
231 vi.stubGlobal("fetch", fetchMock);
232
233 const { resolveInitialServerAuthGateState } = await import("./environments/primary");213 const { resolveInitialServerAuthGateState } = await import("./environments/primary");
234214
235 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({215 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
236 status: "requires-auth",216 status: "requires-auth",
237 auth: {217 auth: LOOPBACK_AUTH,
238 policy: "loopback-browser",
239 bootstrapMethods: ["one-time-token"],
240 sessionMethods: ["browser-session-cookie"],
241 sessionCookieName: "t3_session",
242 },
243 });218 });
244 });219 });
245220
...@@ -251,15 +226,10 @@ describe("resolveInitialServerAuthGateState", () => {...@@ -251,15 +226,10 @@ describe("resolveInitialServerAuthGateState", () => {
251 .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 }))226 .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 }))
252 .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 }))227 .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 }))
253 .mockResolvedValueOnce(228 .mockResolvedValueOnce(
254 sessionResponse({229 new Response(
255 authenticated: false,230 JSON.stringify(encodeAuthSessionState(unauthenticatedSession(LOOPBACK_AUTH))),
256 auth: {231 { status: 200, headers: { "content-type": "application/json" } },
257 policy: "loopback-browser",232 ),
258 bootstrapMethods: ["one-time-token"],
259 sessionMethods: ["browser-session-cookie"],
260 sessionCookieName: "t3_session",
261 },
262 }),
263 );233 );
264 vi.stubGlobal("fetch", fetchMock);234 vi.stubGlobal("fetch", fetchMock);
265235
...@@ -270,12 +240,7 @@ describe("resolveInitialServerAuthGateState", () => {...@@ -270,12 +240,7 @@ describe("resolveInitialServerAuthGateState", () => {
270240
271 await expect(gateStatePromise).resolves.toEqual({241 await expect(gateStatePromise).resolves.toEqual({
272 status: "requires-auth",242 status: "requires-auth",
273 auth: {243 auth: LOOPBACK_AUTH,
274 policy: "loopback-browser",
275 bootstrapMethods: ["one-time-token"],
276 sessionMethods: ["browser-session-cookie"],
277 sessionCookieName: "t3_session",
278 },
279 });244 });
280 expect(fetchMock).toHaveBeenCalledTimes(4);245 expect(fetchMock).toHaveBeenCalledTimes(4);
281 });246 });
...@@ -298,136 +263,60 @@ describe("resolveInitialServerAuthGateState", () => {...@@ -298,136 +263,60 @@ describe("resolveInitialServerAuthGateState", () => {
298 });263 });
299264
300 it("allows manual token submission after the initial auth check requires pairing", async () => {265 it("allows manual token submission after the initial auth check requires pairing", async () => {
301 const fetchMock = vi266 const nextSession = sequence(
302 .fn<typeof fetch>()267 unauthenticatedSession(LOOPBACK_AUTH),
303 .mockResolvedValueOnce(268 authenticatedSession(LOOPBACK_AUTH),
304 sessionResponse({269 );
305 authenticated: false,270 const testApi = await installAuthApi({
306 auth: {271 session: nextSession,
307 policy: "loopback-browser",272 browserSession: () => Effect.succeed(browserSession(["orchestration:read"])),
308 bootstrapMethods: ["one-time-token"],273 });
309 sessionMethods: ["browser-session-cookie"],
310 sessionCookieName: "t3_session",
311 },
312 }),
313 )
314 .mockResolvedValueOnce(
315 jsonResponse({
316 authenticated: true,
317 sessionMethod: "browser-session-cookie",
318 expiresAt: "2026-04-05T00:00:00.000Z",
319 }),
320 )
321 .mockResolvedValueOnce(
322 sessionResponse({
323 authenticated: true,
324 auth: {
325 policy: "loopback-browser",
326 bootstrapMethods: ["one-time-token"],
327 sessionMethods: ["browser-session-cookie"],
328 sessionCookieName: "t3_session",
329 },
330 sessionMethod: "browser-session-cookie",
331 expiresAt: "2026-04-05T00:00:00.000Z",
332 }),
333 );
334 vi.stubGlobal("fetch", fetchMock);
335 installTestBrowser("http://localhost/");
336
337 const { resolveInitialServerAuthGateState, submitServerAuthCredential } =274 const { resolveInitialServerAuthGateState, submitServerAuthCredential } =
338 await import("./environments/primary");275 await import("./environments/primary");
339276
340 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({277 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
341 status: "requires-auth",278 status: "requires-auth",
342 auth: {279 auth: LOOPBACK_AUTH,
343 policy: "loopback-browser",
344 bootstrapMethods: ["one-time-token"],
345 sessionMethods: ["browser-session-cookie"],
346 sessionCookieName: "t3_session",
347 },
348 });280 });
349 await expect(submitServerAuthCredential("retry-token")).resolves.toBeUndefined();281 await expect(submitServerAuthCredential("retry-token")).resolves.toBeUndefined();
350 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({282 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
351 status: "authenticated",283 status: "authenticated",
352 });284 });
353 expect(fetchMock).toHaveBeenCalledTimes(3);285 expect(testApi.calls.browserSession).toEqual([{ credential: "retry-token" }]);
286 expect(testApi.calls.session).toBe(2);
354 });287 });
355288
356 it("surfaces a friendly error message when an invalid pairing token is submitted", async () => {289 it("surfaces a friendly error message when an invalid pairing token is submitted", async () => {
357 const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce(290 const testApi = await installAuthApi({
358 jsonResponse(291 browserSession: () =>
359 {292 Effect.fail(
360 error: "Invalid bootstrap credential.",293 new EnvironmentAuthInvalidError({
361 },294 code: "auth_invalid",
362 {295 reason: "invalid_credential",
363 status: 401,296 traceId: "trace-invalid-credential",
364 },297 }),
365 ),298 ),
366 );299 });
367 vi.stubGlobal("fetch", fetchMock);
368300
369 const { submitServerAuthCredential } = await import("./environments/primary");301 const { submitServerAuthCredential } = await import("./environments/primary");
370302
371 await expect(submitServerAuthCredential("bad-token")).rejects.toThrow(303 await expect(submitServerAuthCredential("bad-token")).rejects.toThrow(
372 "Invalid pairing token. Check the token and try again.",304 "Invalid pairing token. Check the token and try again.",
373 );305 );
374 expect(fetchMock).toHaveBeenCalledWith("http://localhost/api/auth/bootstrap", {306 expect(testApi.calls.browserSession).toEqual([{ credential: "bad-token" }]);
375 body: JSON.stringify({ credential: "bad-token" }),
376 credentials: "include",
377 headers: {
378 "content-type": "application/json",
379 },
380 method: "POST",
381 });
382 });307 });
383308
384 it("waits for the authenticated session to become observable after silent desktop bootstrap", async () => {309 it("waits for the authenticated session to become observable after silent desktop bootstrap", async () => {
385 vi.useFakeTimers();310 vi.useFakeTimers();
386 const fetchMock = vi311 const nextSession = sequence(
387 .fn<typeof fetch>()312 unauthenticatedSession(DESKTOP_AUTH),
388 .mockResolvedValueOnce(313 unauthenticatedSession(DESKTOP_AUTH),
389 sessionResponse({314 authenticatedSession(DESKTOP_AUTH),
390 authenticated: false,315 );
391 auth: {316 const testApi = await installAuthApi({
392 policy: "desktop-managed-local",317 session: nextSession,
393 bootstrapMethods: ["desktop-bootstrap"],318 browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])),
394 sessionMethods: ["browser-session-cookie"],319 });
395 sessionCookieName: "t3_session",
396 },
397 }),
398 )
399 .mockResolvedValueOnce(
400 jsonResponse({
401 authenticated: true,
402 sessionMethod: "browser-session-cookie",
403 expiresAt: "2026-04-05T00:00:00.000Z",
404 }),
405 )
406 .mockResolvedValueOnce(
407 sessionResponse({
408 authenticated: false,
409 auth: {
410 policy: "desktop-managed-local",
411 bootstrapMethods: ["desktop-bootstrap"],
412 sessionMethods: ["browser-session-cookie"],
413 sessionCookieName: "t3_session",
414 },
415 }),
416 )
417 .mockResolvedValueOnce(
418 sessionResponse({
419 authenticated: true,
420 auth: {
421 policy: "desktop-managed-local",
422 bootstrapMethods: ["desktop-bootstrap"],
423 sessionMethods: ["browser-session-cookie"],
424 sessionCookieName: "t3_session",
425 },
426 sessionMethod: "browser-session-cookie",
427 expiresAt: "2026-04-05T00:00:00.000Z",
428 }),
429 );
430 vi.stubGlobal("fetch", fetchMock);
431320
432 const testWindow = installTestBrowser("http://localhost/");321 const testWindow = installTestBrowser("http://localhost/");
433 testWindow.desktopBridge = {322 testWindow.desktopBridge = {
...@@ -444,43 +333,14 @@ describe("resolveInitialServerAuthGateState", () => {...@@ -444,43 +333,14 @@ describe("resolveInitialServerAuthGateState", () => {
444 const gateStatePromise = resolveInitialServerAuthGateState();333 const gateStatePromise = resolveInitialServerAuthGateState();
445 await vi.advanceTimersByTimeAsync(100);334 await vi.advanceTimersByTimeAsync(100);
446335
447 await expect(gateStatePromise).resolves.toEqual({336 await expect(gateStatePromise).resolves.toEqual({ status: "authenticated" });
448 status: "authenticated",337 expect(testApi.calls.session).toBe(3);
449 });
450 expect(fetchMock).toHaveBeenCalledTimes(4);
451 expect(fetchMock.mock.calls[2]?.[0]).toBe("http://localhost:3773/api/auth/session");
452 expect(fetchMock.mock.calls[3]?.[0]).toBe("http://localhost:3773/api/auth/session");
453 });338 });
454339
455 it("memoizes the authenticated gate state after the first successful read", async () => {340 it("memoizes the authenticated gate state after the first successful read", async () => {
456 const fetchMock = vi341 const testApi = await installAuthApi({
457 .fn<typeof fetch>()342 session: sequence(authenticatedSession(LOOPBACK_AUTH), unauthenticatedSession(LOOPBACK_AUTH)),
458 .mockResolvedValueOnce(343 });
459 sessionResponse({
460 authenticated: true,
461 auth: {
462 policy: "loopback-browser",
463 bootstrapMethods: ["one-time-token"],
464 sessionMethods: ["browser-session-cookie"],
465 sessionCookieName: "t3_session",
466 },
467 sessionMethod: "browser-session-cookie",
468 expiresAt: "2026-04-05T00:00:00.000Z",
469 }),
470 )
471 .mockResolvedValueOnce(
472 sessionResponse({
473 authenticated: false,
474 auth: {
475 policy: "loopback-browser",
476 bootstrapMethods: ["one-time-token"],
477 sessionMethods: ["browser-session-cookie"],
478 sessionCookieName: "t3_session",
479 },
480 }),
481 );
482 vi.stubGlobal("fetch", fetchMock);
483
484 const { resolveInitialServerAuthGateState } = await import("./environments/primary");344 const { resolveInitialServerAuthGateState } = await import("./environments/primary");
485345
486 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({346 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
...@@ -489,35 +349,33 @@ describe("resolveInitialServerAuthGateState", () => {...@@ -489,35 +349,33 @@ describe("resolveInitialServerAuthGateState", () => {
489 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({349 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
490 status: "authenticated",350 status: "authenticated",
491 });351 });
492 expect(fetchMock).toHaveBeenCalledTimes(1);352 expect(testApi.calls.session).toBe(1);
493 });353 });
494354
495 it("creates a pairing credential from the authenticated auth endpoint", async () => {355 it("creates a pairing credential from the authenticated auth endpoint", async () => {
496 const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce(356 const testApi = await installAuthApi({
497 jsonResponse({357 pairingCredential: (payload) =>
498 id: "pairing-link-1",358 Effect.succeed({
499 credential: "pairing-token",359 id: "pairing-link-1",
500 label: "Julius iPhone",360 credential: "pairing-token",
501 expiresAt: "2026-04-05T00:00:00.000Z",361 ...(payload.label === undefined ? {} : { label: payload.label }),
502 }),362 expiresAt: SESSION_EXPIRES_AT,
503 );363 }),
504 vi.stubGlobal("fetch", fetchMock);364 });
505
506 const { createServerPairingCredential } = await import("./environments/primary");365 const { createServerPairingCredential } = await import("./environments/primary");
507366
508 await expect(createServerPairingCredential("Julius iPhone")).resolves.toEqual({367 const credential = await createServerPairingCredential({
368 label: "Julius iPhone",
369 scopes: ["orchestration:read"],
370 });
371 expect(credential).toMatchObject({
509 id: "pairing-link-1",372 id: "pairing-link-1",
510 credential: "pairing-token",373 credential: "pairing-token",
511 label: "Julius iPhone",374 label: "Julius iPhone",
512 expiresAt: "2026-04-05T00:00:00.000Z",
513 });
514 expect(fetchMock).toHaveBeenCalledWith("http://localhost/api/auth/pairing-token", {
515 body: JSON.stringify({ label: "Julius iPhone" }),
516 credentials: "include",
517 headers: {
518 "content-type": "application/json",
519 },
520 method: "POST",
521 });375 });
376 expect(DateTime.formatIso(credential.expiresAt)).toBe("2026-04-05T00:00:00.000Z");
377 expect(testApi.calls.pairingCredential).toEqual([
378 { label: "Julius iPhone", scopes: ["orchestration:read"] },
379 ]);
522 });380 });
523});381});
apps/web/src/components/ChatView.browser.tsx+1-1
...@@ -194,7 +194,7 @@ function createBaseServerConfig(): ServerConfig {...@@ -194,7 +194,7 @@ function createBaseServerConfig(): ServerConfig {
194 auth: {194 auth: {
195 policy: "loopback-browser",195 policy: "loopback-browser",
196 bootstrapMethods: ["one-time-token"],196 bootstrapMethods: ["one-time-token"],
197 sessionMethods: ["browser-session-cookie", "bearer-session-token"],197 sessionMethods: ["browser-session-cookie", "bearer-access-token"],
198 sessionCookieName: "t3_session",198 sessionCookieName: "t3_session",
199 },199 },
200 cwd: "/repo/project",200 cwd: "/repo/project",
apps/web/src/components/KeybindingsToast.browser.tsx+1-1
...@@ -96,7 +96,7 @@ function createBaseServerConfig(): ServerConfig {...@@ -96,7 +96,7 @@ function createBaseServerConfig(): ServerConfig {
96 auth: {96 auth: {
97 policy: "loopback-browser",97 policy: "loopback-browser",
98 bootstrapMethods: ["one-time-token"],98 bootstrapMethods: ["one-time-token"],
99 sessionMethods: ["browser-session-cookie", "bearer-session-token"],99 sessionMethods: ["browser-session-cookie", "bearer-access-token"],
100 sessionCookieName: "t3_session",100 sessionCookieName: "t3_session",
101 },101 },
102 cwd: "/repo/project",102 cwd: "/repo/project",
apps/web/src/components/settings/ConnectionsSettings.tsx+209-52
...@@ -9,7 +9,18 @@ import {...@@ -9,7 +9,18 @@ import {
9} from "lucide-react";9} from "lucide-react";
10import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from "react";10import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from "react";
11import {11import {
12 AuthAccessReadScope,
13 AuthAccessWriteScope,
14 AuthAdministrativeScopes,
15 AuthOrchestrationOperateScope,
16 AuthOrchestrationReadScope,
17 AuthRelayReadScope,
18 AuthRelayWriteScope,
19 AuthReviewWriteScope,
20 AuthStandardClientScopes,
21 AuthTerminalOperateScope,
12 type AuthClientSession,22 type AuthClientSession,
23 type AuthEnvironmentScope,
13 type AuthPairingLink,24 type AuthPairingLink,
14 type AdvertisedEndpoint,25 type AdvertisedEndpoint,
15 type DesktopDiscoveredSshHost,26 type DesktopDiscoveredSshHost,
...@@ -31,6 +42,7 @@ import {...@@ -31,6 +42,7 @@ import {
31 useRelativeTimeTick,42 useRelativeTimeTick,
32} from "./settingsLayout";43} from "./settingsLayout";
33import { Input } from "../ui/input";44import { Input } from "../ui/input";
45import { Checkbox } from "../ui/checkbox";
34import {46import {
35 Dialog,47 Dialog,
36 DialogClose,48 DialogClose,
...@@ -75,11 +87,11 @@ import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl";...@@ -75,11 +87,11 @@ import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl";
75import { readHostedPairingRequest } from "../../hostedPairing";87import { readHostedPairingRequest } from "../../hostedPairing";
76import {88import {
77 createServerPairingCredential,89 createServerPairingCredential,
78 fetchSessionState,
79 revokeOtherServerClientSessions,90 revokeOtherServerClientSessions,
80 revokeServerClientSession,91 revokeServerClientSession,
81 revokeServerPairingLink,92 revokeServerPairingLink,
82 isLoopbackHostname,93 isLoopbackHostname,
94 usePrimarySessionState,
83 type ServerClientSessionRecord,95 type ServerClientSessionRecord,
84 type ServerPairingLinkRecord,96 type ServerPairingLinkRecord,
85} from "~/environments/primary";97} from "~/environments/primary";
...@@ -114,6 +126,97 @@ function formatAccessTimestamp(value: string): string {...@@ -114,6 +126,97 @@ function formatAccessTimestamp(value: string): string {
114 return accessTimestampFormatter.format(parsed);126 return accessTimestampFormatter.format(parsed);
115}127}
116128
129const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{
130 readonly scope: AuthEnvironmentScope;
131 readonly title: string;
132 readonly description: string;
133}> = [
134 {
135 scope: AuthOrchestrationReadScope,
136 title: "View environment",
137 description: "Read threads, status, diffs, and configuration.",
138 },
139 {
140 scope: AuthOrchestrationOperateScope,
141 title: "Operate tasks",
142 description: "Start tasks and perform changes in the environment.",
143 },
144 {
145 scope: AuthTerminalOperateScope,
146 title: "Use terminals",
147 description: "Create terminals and send input to running shells.",
148 },
149 {
150 scope: AuthReviewWriteScope,
151 title: "Write reviews",
152 description: "Create comments while reviewing changes.",
153 },
154 {
155 scope: AuthAccessReadScope,
156 title: "View access",
157 description: "Inspect pairing links and authorized clients.",
158 },
159 {
160 scope: AuthAccessWriteScope,
161 title: "Manage access",
162 description: "Issue and revoke credentials for other clients.",
163 },
164 {
165 scope: AuthRelayReadScope,
166 title: "View relay",
167 description: "Inspect managed relay connectivity.",
168 },
169 {
170 scope: AuthRelayWriteScope,
171 title: "Manage relay",
172 description: "Change managed tunnel connectivity.",
173 },
174];
175
176function AccessScopeSummary({
177 scopes,
178 label,
179}: {
180 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
181 readonly label: string;
182}) {
183 const scopeCountLabel = `${scopes.length} ${scopes.length === 1 ? "scope" : "scopes"}`;
184
185 return (
186 <Popover>
187 <PopoverTrigger
188 openOnHover
189 delay={250}
190 closeDelay={100}
191 render={
192 <button
193 type="button"
194 aria-label={`${label}: show ${scopeCountLabel}`}
195 className="cursor-help underline decoration-border underline-offset-2 outline-hidden hover:text-foreground focus-visible:text-foreground"
196 />
197 }
198 >
199 {scopeCountLabel}
200 </PopoverTrigger>
201 <PopoverPopup
202 side="top"
203 align="start"
204 tooltipStyle
205 className="w-max max-w-80 whitespace-normal"
206 >
207 <p className="mb-1 font-medium">Granted scopes</p>
208 <div className="flex flex-col gap-0.5">
209 {scopes.map((scope) => (
210 <code key={scope} className="font-mono text-foreground/85">
211 {scope}
212 </code>
213 ))}
214 </div>
215 </PopoverPopup>
216 </Popover>
217 );
218}
219
117type ConnectionStatusDotProps = {220type ConnectionStatusDotProps = {
118 tooltipText?: string | null;221 tooltipText?: string | null;
119 dotClassName: string;222 dotClassName: string;
...@@ -629,8 +732,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({...@@ -629,8 +732,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
629732
630 const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt);733 const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt);
631734
632 const roleLabel = pairingLink.role === "owner" ? "Owner" : "Client";735 const primaryLabel = pairingLink.label ?? "Pairing link";
633 const primaryLabel = pairingLink.label ?? `${roleLabel} link`;
634 const defaultEndpointCopyOption =736 const defaultEndpointCopyOption =
635 endpointCopyOptions.find((option) => option.key === defaultEndpointKey) ??737 endpointCopyOptions.find((option) => option.key === defaultEndpointKey) ??
636 endpointCopyOptions[0] ??738 endpointCopyOptions[0] ??
...@@ -759,7 +861,9 @@ const PairingLinkListRow = memo(function PairingLinkListRow({...@@ -759,7 +861,9 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
759 </Popover>861 </Popover>
760 </div>862 </div>
761 <p className="text-xs text-muted-foreground" title={expiresAbsolute}>863 <p className="text-xs text-muted-foreground" title={expiresAbsolute}>
762 {[roleLabel, formatExpiresInLabel(pairingLink.expiresAt, nowMs)].join(" · ")}864 {formatExpiresInLabel(pairingLink.expiresAt, nowMs)}
865 <span aria-hidden> · </span>
866 <AccessScopeSummary scopes={pairingLink.scopes} label="Pairing link scopes" />
763 </p>867 </p>
764 {shareablePairingUrl === null ? (868 {shareablePairingUrl === null ? (
765 <p className="text-[11px] text-muted-foreground/70">869 <p className="text-[11px] text-muted-foreground/70">
...@@ -900,7 +1004,6 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({...@@ -900,7 +1004,6 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({
900 : lastConnectedAt1004 : lastConnectedAt
901 ? `Last connected at ${formatAccessTimestamp(lastConnectedAt)}`1005 ? `Last connected at ${formatAccessTimestamp(lastConnectedAt)}`
902 : "Not connected yet.";1006 : "Not connected yet.";
903 const roleLabel = clientSession.role === "owner" ? "Owner" : "Client";
904 const deviceInfoBits = [1007 const deviceInfoBits = [
905 clientSession.client.deviceType !== "unknown"1008 clientSession.client.deviceType !== "unknown"
906 ? clientSession.client.deviceType[0]?.toUpperCase() + clientSession.client.deviceType.slice(1)1009 ? clientSession.client.deviceType[0]?.toUpperCase() + clientSession.client.deviceType.slice(1)
...@@ -932,7 +1035,13 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({...@@ -932,7 +1035,13 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({
932 ) : null}1035 ) : null}
933 </div>1036 </div>
934 <p className="text-xs text-muted-foreground">1037 <p className="text-xs text-muted-foreground">
935 {[roleLabel, ...deviceInfoBits].join(" · ")}1038 {deviceInfoBits.length > 0 ? (
1039 <>
1040 {deviceInfoBits.join(" · ")}
1041 <span aria-hidden> · </span>
1042 </>
1043 ) : null}
1044 <AccessScopeSummary scopes={clientSession.scopes} label="Client scopes" />
936 </p>1045 </p>
937 </div>1046 </div>
938 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">1047 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
...@@ -965,13 +1074,17 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio...@@ -965,13 +1074,17 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
965}: AuthorizedClientsHeaderActionProps) {1074}: AuthorizedClientsHeaderActionProps) {
966 const [dialogOpen, setDialogOpen] = useState(false);1075 const [dialogOpen, setDialogOpen] = useState(false);
967 const [pairingLabel, setPairingLabel] = useState("");1076 const [pairingLabel, setPairingLabel] = useState("");
1077 const [pairingScopes, setPairingScopes] = useState<ReadonlyArray<AuthEnvironmentScope>>([
1078 ...AuthStandardClientScopes,
1079 ]);
968 const [isCreatingPairingLink, setIsCreatingPairingLink] = useState(false);1080 const [isCreatingPairingLink, setIsCreatingPairingLink] = useState(false);
9691081
970 const handleCreatePairingLink = useCallback(async () => {1082 const handleCreatePairingLink = useCallback(async () => {
971 setIsCreatingPairingLink(true);1083 setIsCreatingPairingLink(true);
972 try {1084 try {
973 await createServerPairingCredential(pairingLabel);1085 await createServerPairingCredential({ label: pairingLabel, scopes: pairingScopes });
974 setPairingLabel("");1086 setPairingLabel("");
1087 setPairingScopes([...AuthStandardClientScopes]);
975 setDialogOpen(false);1088 setDialogOpen(false);
976 } catch (error) {1089 } catch (error) {
977 const message = error instanceof Error ? error.message : "Failed to create pairing URL.";1090 const message = error instanceof Error ? error.message : "Failed to create pairing URL.";
...@@ -985,7 +1098,13 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio...@@ -985,7 +1098,13 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
985 } finally {1098 } finally {
986 setIsCreatingPairingLink(false);1099 setIsCreatingPairingLink(false);
987 }1100 }
988 }, [pairingLabel]);1101 }, [pairingLabel, pairingScopes]);
1102
1103 const togglePairingScope = useCallback((scope: AuthEnvironmentScope, checked: boolean) => {
1104 setPairingScopes((current) =>
1105 checked ? [...current, scope] : current.filter((currentScope) => currentScope !== scope),
1106 );
1107 }, []);
9891108
990 return (1109 return (
991 <div className="flex items-center gap-2">1110 <div className="flex items-center gap-2">
...@@ -1005,6 +1124,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio...@@ -1005,6 +1124,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
1005 setDialogOpen(open);1124 setDialogOpen(open);
1006 if (!open) {1125 if (!open) {
1007 setPairingLabel("");1126 setPairingLabel("");
1127 setPairingScopes([...AuthStandardClientScopes]);
1008 }1128 }
1009 }}1129 }}
1010 >1130 >
...@@ -1016,7 +1136,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio...@@ -1016,7 +1136,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
1016 </Button>1136 </Button>
1017 }1137 }
1018 />1138 />
1019 <DialogPopup className="max-w-sm">1139 <DialogPopup className="max-w-md">
1020 <DialogHeader>1140 <DialogHeader>
1021 <DialogTitle>Create pairing link</DialogTitle>1141 <DialogTitle>Create pairing link</DialogTitle>
1022 <DialogDescription>1142 <DialogDescription>
...@@ -1024,7 +1144,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio...@@ -1024,7 +1144,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
1024 authorized client.1144 authorized client.
1025 </DialogDescription>1145 </DialogDescription>
1026 </DialogHeader>1146 </DialogHeader>
1027 <DialogPanel>1147 <DialogPanel className="space-y-5">
1028 <label className="block">1148 <label className="block">
1029 <span className="mb-1.5 block text-xs font-medium text-foreground">1149 <span className="mb-1.5 block text-xs font-medium text-foreground">
1030 Client label (optional)1150 Client label (optional)
...@@ -1037,6 +1157,62 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio...@@ -1037,6 +1157,62 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
1037 autoFocus1157 autoFocus
1038 />1158 />
1039 </label>1159 </label>
1160 <section className="space-y-3">
1161 <div className="flex items-center justify-between gap-3">
1162 <div>
1163 <h3 className="text-xs font-medium text-foreground">Permissions</h3>
1164 <p className="text-xs text-muted-foreground">
1165 Limit what the paired client can do.
1166 </p>
1167 </div>
1168 <div className="flex gap-1">
1169 <Button
1170 size="xs"
1171 variant="outline"
1172 disabled={isCreatingPairingLink}
1173 onClick={() => setPairingScopes([AuthOrchestrationReadScope])}
1174 >
1175 Read only
1176 </Button>
1177 <Button
1178 size="xs"
1179 variant="outline"
1180 disabled={isCreatingPairingLink}
1181 onClick={() => setPairingScopes([...AuthStandardClientScopes])}
1182 >
1183 Standard
1184 </Button>
1185 </div>
1186 </div>
1187 <div className="divide-y divide-border/60 rounded-lg border border-input bg-muted/25">
1188 {PAIRING_SCOPE_OPTIONS.map(({ scope, title, description }) => (
1189 <label
1190 key={scope}
1191 className="flex cursor-pointer items-start gap-3 px-3 py-2.5 transition-colors hover:bg-muted/40"
1192 >
1193 <Checkbox
1194 className="mt-0.5"
1195 checked={pairingScopes.includes(scope)}
1196 disabled={isCreatingPairingLink}
1197 onCheckedChange={(checked) => togglePairingScope(scope, checked === true)}
1198 />
1199 <span className="min-w-0">
1200 <span className="block text-xs font-medium text-foreground">{title}</span>
1201 <span className="block text-xs leading-snug text-muted-foreground">
1202 {description}
1203 </span>
1204 </span>
1205 </label>
1206 ))}
1207 </div>
1208 {pairingScopes.length === 0 ? (
1209 <p className="text-xs text-destructive">Select at least one permission.</p>
1210 ) : pairingScopes.includes(AuthAccessWriteScope) ? (
1211 <p className="text-xs text-warning">
1212 This client can create or revoke access for other devices.
1213 </p>
1214 ) : null}
1215 </section>
1040 </DialogPanel>1216 </DialogPanel>
1041 <DialogFooter variant="bare">1217 <DialogFooter variant="bare">
1042 <Button1218 <Button
...@@ -1046,7 +1222,10 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio...@@ -1046,7 +1222,10 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
1046 >1222 >
1047 Cancel1223 Cancel
1048 </Button>1224 </Button>
1049 <Button disabled={isCreatingPairingLink} onClick={() => void handleCreatePairingLink()}>1225 <Button
1226 disabled={isCreatingPairingLink || pairingScopes.length === 0}
1227 onClick={() => void handleCreatePairingLink()}
1228 >
1050 {isCreatingPairingLink ? "Creating…" : "Create link"}1229 {isCreatingPairingLink ? "Creating…" : "Create link"}
1051 </Button>1230 </Button>
1052 </DialogFooter>1231 </DialogFooter>
...@@ -1290,14 +1469,12 @@ function SavedBackendListRow({...@@ -1290,14 +1469,12 @@ function SavedBackendListRow({
1290 : connectionState === "error"1469 : connectionState === "error"
1291 ? "bg-destructive"1470 ? "bg-destructive"
1292 : "bg-muted-foreground/40";1471 : "bg-muted-foreground/40";
1293 const roleLabel = runtime?.role ? (runtime.role === "owner" ? "Owner" : "Client") : null;
1294 const descriptorLabel = runtime?.descriptor?.label ?? null;1472 const descriptorLabel = runtime?.descriptor?.label ?? null;
1295 const displayLabel = descriptorLabel ?? record.label;1473 const displayLabel = descriptorLabel ?? record.label;
1296 const statusTooltip = getSavedBackendStatusTooltip(runtime, record, nowMs);1474 const statusTooltip = getSavedBackendStatusTooltip(runtime, record, nowMs);
1297 const versionMismatch = resolveServerConfigVersionMismatch(runtime?.serverConfig);1475 const versionMismatch = resolveServerConfigVersionMismatch(runtime?.serverConfig);
1298 const metadataBits = [1476 const metadataBits = [
1299 record.desktopSsh ? `SSH ${formatDesktopSshTarget(record.desktopSsh)}` : null,1477 record.desktopSsh ? `SSH ${formatDesktopSshTarget(record.desktopSsh)}` : null,
1300 roleLabel,
1301 record.lastConnectedAt1478 record.lastConnectedAt
1302 ? `Last connected ${formatAccessTimestamp(record.lastConnectedAt)}`1479 ? `Last connected ${formatAccessTimestamp(record.lastConnectedAt)}`
1303 : null,1480 : null,
...@@ -1317,8 +1494,14 @@ function SavedBackendListRow({...@@ -1317,8 +1494,14 @@ function SavedBackendListRow({
1317 />1494 />
1318 <h3 className="text-sm font-medium text-foreground">{displayLabel}</h3>1495 <h3 className="text-sm font-medium text-foreground">{displayLabel}</h3>
1319 </div>1496 </div>
1320 {metadataBits.length > 0 ? (1497 {metadataBits.length > 0 || runtime?.scopes ? (
1321 <p className="text-xs text-muted-foreground">{metadataBits.join(" · ")}</p>1498 <p className="text-xs text-muted-foreground">
1499 {metadataBits.length > 0 ? metadataBits.join(" · ") : null}
1500 {metadataBits.length > 0 && runtime?.scopes ? <span aria-hidden> · </span> : null}
1501 {runtime?.scopes ? (
1502 <AccessScopeSummary scopes={runtime.scopes} label="Granted scopes" />
1503 ) : null}
1504 </p>
1322 ) : null}1505 ) : null}
1323 {versionMismatch ? (1506 {versionMismatch ? (
1324 <p className="flex items-center gap-1 text-warning text-xs">1507 <p className="flex items-center gap-1 text-warning text-xs">
...@@ -1401,12 +1584,13 @@ const DesktopSshHostRow = memo(function DesktopSshHostRow({...@@ -1401,12 +1584,13 @@ const DesktopSshHostRow = memo(function DesktopSshHostRow({
14011584
1402export function ConnectionsSettings() {1585export function ConnectionsSettings() {
1403 const desktopBridge = window.desktopBridge;1586 const desktopBridge = window.desktopBridge;
1404 const [currentSessionRole, setCurrentSessionRole] = useState<"owner" | "client" | null>(1587 const primarySessionState = usePrimarySessionState();
1405 desktopBridge ? "owner" : null,1588 const currentSessionScopes = desktopBridge
1406 );1589 ? AuthAdministrativeScopes
1407 const [currentAuthPolicy, setCurrentAuthPolicy] = useState<1590 : primarySessionState.data?.authenticated
1408 "desktop-managed-local" | "loopback-browser" | "remote-reachable" | "unsafe-no-auth" | null1591 ? (primarySessionState.data.scopes ?? null)
1409 >(desktopBridge ? null : null);1592 : null;
1593 const currentAuthPolicy = desktopBridge ? null : (primarySessionState.data?.auth.policy ?? null);
1410 const savedEnvironmentsById = useSavedEnvironmentRegistryStore((state) => state.byId);1594 const savedEnvironmentsById = useSavedEnvironmentRegistryStore((state) => state.byId);
1411 const savedEnvironmentIds = useMemo(1595 const savedEnvironmentIds = useMemo(
1412 () =>1596 () =>
...@@ -1516,7 +1700,7 @@ export function ConnectionsSettings() {...@@ -1516,7 +1700,7 @@ export function ConnectionsSettings() {
1516 const setDefaultAdvertisedEndpointKey = useUiStateStore(1700 const setDefaultAdvertisedEndpointKey = useUiStateStore(
1517 (state) => state.setDefaultAdvertisedEndpointKey,1701 (state) => state.setDefaultAdvertisedEndpointKey,
1518 );1702 );
1519 const canManageLocalBackend = currentSessionRole === "owner";1703 const canManageLocalBackend = currentSessionScopes?.includes(AuthAccessWriteScope) ?? false;
1520 const isLocalBackendNetworkAccessible = desktopBridge1704 const isLocalBackendNetworkAccessible = desktopBridge
1521 ? desktopServerExposureState?.mode === "network-accessible"1705 ? desktopServerExposureState?.mode === "network-accessible"
1522 : currentAuthPolicy === "remote-reachable";1706 : currentAuthPolicy === "remote-reachable";
...@@ -1929,30 +2113,6 @@ export function ConnectionsSettings() {...@@ -1929,30 +2113,6 @@ export function ConnectionsSettings() {
1929 savedBackendMode,2113 savedBackendMode,
1930 ]);2114 ]);
19312115
1932 useEffect(() => {
1933 if (desktopBridge) {
1934 setCurrentSessionRole("owner");
1935 return;
1936 }
1937
1938 let cancelled = false;
1939 void fetchSessionState()
1940 .then((session) => {
1941 if (cancelled) return;
1942 setCurrentSessionRole(session.authenticated ? (session.role ?? null) : null);
1943 setCurrentAuthPolicy(session.auth.policy);
1944 })
1945 .catch(() => {
1946 if (cancelled) return;
1947 setCurrentSessionRole(null);
1948 setCurrentAuthPolicy(null);
1949 });
1950
1951 return () => {
1952 cancelled = true;
1953 };
1954 }, [desktopBridge]);
1955
1956 useEffect(() => {2116 useEffect(() => {
1957 if (!canManageLocalBackend) return;2117 if (!canManageLocalBackend) return;
19582118
...@@ -2065,10 +2225,7 @@ export function ConnectionsSettings() {...@@ -2065,10 +2225,7 @@ export function ConnectionsSettings() {
2065 setDesktopAdvertisedEndpoints([]);2225 setDesktopAdvertisedEndpoints([]);
2066 setDesktopServerExposureError(null);2226 setDesktopServerExposureError(null);
2067 }, [canManageLocalBackend]);2227 }, [canManageLocalBackend]);
2068 const visibleDesktopPairingLinks = useMemo(2228 const visibleDesktopPairingLinks = desktopPairingLinks;
2069 () => desktopPairingLinks.filter((pairingLink) => pairingLink.role === "client"),
2070 [desktopPairingLinks],
2071 );
2072 const tailscaleHttpsEndpoint = useMemo(2229 const tailscaleHttpsEndpoint = useMemo(
2073 () => desktopAdvertisedEndpoints.find(isTailscaleHttpsEndpoint) ?? null,2230 () => desktopAdvertisedEndpoints.find(isTailscaleHttpsEndpoint) ?? null,
2074 [desktopAdvertisedEndpoints],2231 [desktopAdvertisedEndpoints],
...@@ -2650,8 +2807,8 @@ export function ConnectionsSettings() {...@@ -2650,8 +2807,8 @@ export function ConnectionsSettings() {
2650 ) : (2807 ) : (
2651 <SettingsSection title="Local backend access">2808 <SettingsSection title="Local backend access">
2652 <SettingsRow2809 <SettingsRow
2653 title="Owner tools"2810 title="Administrative access"
2654 description="Pairing links and client-session management are only available to owner sessions for this backend."2811 description="Pairing links and client-session management require the access:write scope for this backend."
2655 />2812 />
2656 </SettingsSection>2813 </SettingsSection>
2657 )}2814 )}
apps/web/src/components/settings/SettingsPanels.browser.tsx+32-21
...@@ -3,6 +3,7 @@ import "../../index.css";...@@ -3,6 +3,7 @@ import "../../index.css";
3import {3import {
4 type AuthAccessStreamEvent,4 type AuthAccessStreamEvent,
5 type AuthAccessSnapshot,5 type AuthAccessSnapshot,
6 type AuthEnvironmentScope,
6 AuthSessionId,7 AuthSessionId,
7 DEFAULT_SERVER_SETTINGS,8 DEFAULT_SERVER_SETTINGS,
8 EnvironmentId,9 EnvironmentId,
...@@ -217,7 +218,7 @@ function createBaseServerConfig(): ServerConfig {...@@ -217,7 +218,7 @@ function createBaseServerConfig(): ServerConfig {
217 auth: {218 auth: {
218 policy: "loopback-browser",219 policy: "loopback-browser",
219 bootstrapMethods: ["one-time-token"],220 bootstrapMethods: ["one-time-token"],
220 sessionMethods: ["browser-session-cookie", "bearer-session-token"],221 sessionMethods: ["browser-session-cookie", "bearer-access-token"],
221 sessionCookieName: "t3_session",222 sessionCookieName: "t3_session",
222 },223 },
223 cwd: "/repo/project",224 cwd: "/repo/project",
...@@ -286,7 +287,7 @@ function createEmptyProcessResourceHistoryResult(): ServerProcessResourceHistory...@@ -286,7 +287,7 @@ function createEmptyProcessResourceHistoryResult(): ServerProcessResourceHistory
286function makePairingLink(input: {287function makePairingLink(input: {
287 readonly id: string;288 readonly id: string;
288 readonly credential: string;289 readonly credential: string;
289 readonly role: "owner" | "client";290 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
290 readonly subject: string;291 readonly subject: string;
291 readonly label?: string;292 readonly label?: string;
292 readonly createdAt: string;293 readonly createdAt: string;
...@@ -302,7 +303,7 @@ function makePairingLink(input: {...@@ -302,7 +303,7 @@ function makePairingLink(input: {
302function makeClientSession(input: {303function makeClientSession(input: {
303 readonly sessionId: string;304 readonly sessionId: string;
304 readonly subject: string;305 readonly subject: string;
305 readonly role: "owner" | "client";306 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
306 readonly method: "browser-session-cookie";307 readonly method: "browser-session-cookie";
307 readonly client?: {308 readonly client?: {
308 readonly label?: string;309 readonly label?: string;
...@@ -394,26 +395,26 @@ const createDesktopBridgeStub = (overrides?: {...@@ -394,26 +395,26 @@ const createDesktopBridgeStub = (overrides?: {
394 },395 },
395 }),396 }),
396 bootstrapSshBearerSession: vi.fn().mockResolvedValue({397 bootstrapSshBearerSession: vi.fn().mockResolvedValue({
397 authenticated: true,398 access_token: "ssh-bearer-token",
398 role: "owner",399 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
399 sessionMethod: "bearer-session-token",400 token_type: "Bearer",
400 expiresAt: "2026-05-01T12:00:00.000Z",401 expires_in: 3_600,
401 sessionToken: "ssh-bearer-token",402 scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read",
402 }),403 }),
403 fetchSshSessionState: vi.fn().mockResolvedValue({404 fetchSshSessionState: vi.fn().mockResolvedValue({
404 authenticated: true,405 authenticated: true,
405 auth: {406 auth: {
406 policy: "remote-reachable",407 policy: "remote-reachable",
407 bootstrapMethods: ["one-time-token"],408 bootstrapMethods: ["one-time-token"],
408 sessionMethods: ["browser-session-cookie", "bearer-session-token"],409 sessionMethods: ["browser-session-cookie", "bearer-access-token"],
409 sessionCookieName: "t3_session",410 sessionCookieName: "t3_session",
410 },411 },
411 role: "owner",412 scopes: ["orchestration:read", "access:write"],
412 sessionMethod: "bearer-session-token",413 sessionMethod: "bearer-access-token",
413 expiresAt: "2026-05-01T12:00:00.000Z",414 expiresAt: "2026-05-01T12:00:00.000Z",
414 }),415 }),
415 issueSshWebSocketToken: vi.fn().mockResolvedValue({416 issueSshWebSocketTicket: vi.fn().mockResolvedValue({
416 token: "ssh-ws-token",417 ticket: "ssh-ws-ticket",
417 expiresAt: "2026-05-01T12:05:00.000Z",418 expiresAt: "2026-05-01T12:05:00.000Z",
418 }),419 }),
419 onSshPasswordPrompt: vi.fn(() => () => {}),420 onSshPasswordPrompt: vi.fn(() => () => {}),
...@@ -508,7 +509,7 @@ describe("GeneralSettingsPanel observability", () => {...@@ -508,7 +509,7 @@ describe("GeneralSettingsPanel observability", () => {
508 makeClientSession({509 makeClientSession({
509 sessionId: "session-owner",510 sessionId: "session-owner",
510 subject: "browser-owner",511 subject: "browser-owner",
511 role: "owner",512 scopes: ["orchestration:read", "access:write"],
512 method: "browser-session-cookie",513 method: "browser-session-cookie",
513 client: {514 client: {
514 label: "Chrome on Mac",515 label: "Chrome on Mac",
...@@ -531,7 +532,7 @@ describe("GeneralSettingsPanel observability", () => {...@@ -531,7 +532,7 @@ describe("GeneralSettingsPanel observability", () => {
531 JSON.stringify({532 JSON.stringify({
532 authenticated: true,533 authenticated: true,
533 auth: createBaseServerConfig().auth,534 auth: createBaseServerConfig().auth,
534 role: "owner",535 scopes: ["orchestration:read", "access:write"],
535 sessionMethod: "browser-session-cookie",536 sessionMethod: "browser-session-cookie",
536 expiresAt: "2036-05-07T00:00:00.000Z",537 expiresAt: "2036-05-07T00:00:00.000Z",
537 }),538 }),
...@@ -776,7 +777,7 @@ describe("GeneralSettingsPanel observability", () => {...@@ -776,7 +777,7 @@ describe("GeneralSettingsPanel observability", () => {
776 makeClientSession({777 makeClientSession({
777 sessionId: "session-owner",778 sessionId: "session-owner",
778 subject: "desktop-bootstrap",779 subject: "desktop-bootstrap",
779 role: "owner",780 scopes: ["orchestration:read", "access:write"],
780 method: "browser-session-cookie",781 method: "browser-session-cookie",
781 client: {782 client: {
782 label: "This Mac",783 label: "This Mac",
...@@ -805,7 +806,7 @@ describe("GeneralSettingsPanel observability", () => {...@@ -805,7 +806,7 @@ describe("GeneralSettingsPanel observability", () => {
805 makePairingLink({806 makePairingLink({
806 id: "pairing-link-1",807 id: "pairing-link-1",
807 credential: "pairing-token",808 credential: "pairing-token",
808 role: "client",809 scopes: ["orchestration:read"],
809 subject: "one-time-token",810 subject: "one-time-token",
810 label: "Julius iPhone",811 label: "Julius iPhone",
811 createdAt: "2036-04-07T00:00:00.000Z",812 createdAt: "2036-04-07T00:00:00.000Z",
...@@ -817,7 +818,7 @@ describe("GeneralSettingsPanel observability", () => {...@@ -817,7 +818,7 @@ describe("GeneralSettingsPanel observability", () => {
817 makeClientSession({818 makeClientSession({
818 sessionId: "session-client",819 sessionId: "session-client",
819 subject: "one-time-token",820 subject: "one-time-token",
820 role: "client",821 scopes: ["orchestration:read"],
821 method: "browser-session-cookie",822 method: "browser-session-cookie",
822 client: {823 client: {
823 label: "Julius iPhone",824 label: "Julius iPhone",
...@@ -867,12 +868,22 @@ describe("GeneralSettingsPanel observability", () => {...@@ -867,12 +868,22 @@ describe("GeneralSettingsPanel observability", () => {
867 await expect.element(page.getByText("This Mac")).toBeInTheDocument();868 await expect.element(page.getByText("This Mac")).toBeInTheDocument();
868 await page.getByRole("button", { name: "Create link", exact: true }).click();869 await page.getByRole("button", { name: "Create link", exact: true }).click();
869 await expect.element(page.getByText("Create pairing link")).toBeInTheDocument();870 await expect.element(page.getByText("Create pairing link")).toBeInTheDocument();
871 await expect.element(page.getByRole("checkbox", { name: /View environment/ })).toBeChecked();
872 await expect.element(page.getByRole("checkbox", { name: /Operate tasks/ })).toBeChecked();
873 await page.getByRole("button", { name: "Read only", exact: true }).click();
874 await expect.element(page.getByRole("checkbox", { name: /View environment/ })).toBeChecked();
875 await expect.element(page.getByRole("checkbox", { name: /Operate tasks/ })).not.toBeChecked();
870 await page.getByRole("button", { name: "Create link", exact: true }).click();876 await page.getByRole("button", { name: "Create link", exact: true }).click();
871 authAccessHarness.emitPairingLinkUpserted(pairingLinks[0]!);877 authAccessHarness.emitPairingLinkUpserted(pairingLinks[0]!);
872 authAccessHarness.emitClientUpserted(clientSessions[1]!);878 authAccessHarness.emitClientUpserted(clientSessions[1]!);
873 await expect879 await expect
874 .element(page.getByText("Client · Mobile · iOS · Safari · 192.168.1.88"))880 .element(page.getByRole("button", { name: "Pairing link scopes: show 1 scope" }))
881 .toBeInTheDocument();
882 await expect
883 .element(page.getByText("Mobile · iOS · Safari · 192.168.1.88"))
875 .toBeInTheDocument();884 .toBeInTheDocument();
885 await page.getByRole("button", { name: "Client scopes: show 1 scope" }).click();
886 await expect.element(page.getByText("orchestration:read", { exact: true })).toBeInTheDocument();
876 await expect887 await expect
877 .element(page.getByRole("button", { name: /^Copy pairing URL for:/ }))888 .element(page.getByRole("button", { name: /^Copy pairing URL for:/ }))
878 .toBeInTheDocument();889 .toBeInTheDocument();
...@@ -893,7 +904,7 @@ describe("GeneralSettingsPanel observability", () => {...@@ -893,7 +904,7 @@ describe("GeneralSettingsPanel observability", () => {
893 makeClientSession({904 makeClientSession({
894 sessionId: "session-owner",905 sessionId: "session-owner",
895 subject: "desktop-bootstrap",906 subject: "desktop-bootstrap",
896 role: "owner",907 scopes: ["orchestration:read", "access:write"],
897 method: "browser-session-cookie",908 method: "browser-session-cookie",
898 client: {909 client: {
899 label: "This Mac",910 label: "This Mac",
...@@ -909,7 +920,7 @@ describe("GeneralSettingsPanel observability", () => {...@@ -909,7 +920,7 @@ describe("GeneralSettingsPanel observability", () => {
909 makeClientSession({920 makeClientSession({
910 sessionId: "session-client",921 sessionId: "session-client",
911 subject: "one-time-token",922 subject: "one-time-token",
912 role: "client",923 scopes: ["orchestration:read"],
913 method: "browser-session-cookie",924 method: "browser-session-cookie",
914 client: {925 client: {
915 label: "Julius iPhone",926 label: "Julius iPhone",
apps/web/src/environments/primary/auth.ts+209-132
...@@ -1,21 +1,25 @@...@@ -1,21 +1,25 @@
1import type {1import type {
2 AuthBootstrapInput,2 AuthBrowserSessionResult,
3 AuthBootstrapResult,
4 AuthClientMetadata,3 AuthClientMetadata,
5 AuthCreatePairingCredentialInput,4 AuthEnvironmentScope,
6 AuthPairingCredentialResult,5 AuthPairingCredentialResult,
7 AuthRevokeClientSessionInput,
8 AuthRevokePairingLinkInput,
9 AuthSessionId,6 AuthSessionId,
10 AuthSessionState,7 AuthSessionState,
11} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import { EnvironmentHttpCommonError } from "@t3tools/contracts";
10import type { EnvironmentHttpCommonError as EnvironmentHttpCommonErrorType } from "@t3tools/contracts";
11import * as DateTime from "effect/DateTime";
12import * as Effect from "effect/Effect";
13import * as Schema from "effect/Schema";
14import { HttpClientError } from "effect/unstable/http";
1215
13import {16import {
14 getPairingTokenFromUrl,17 getPairingTokenFromUrl,
15 stripPairingTokenFromUrl as stripPairingTokenUrl,18 stripPairingTokenFromUrl as stripPairingTokenUrl,
16} from "../../pairingUrl";19} from "../../pairingUrl";
1720
18import { resolvePrimaryEnvironmentHttpUrl } from "./target";21import { PrimaryEnvironmentHttpClient } from "./httpClient";
22import { runPrimaryHttp } from "../../lib/runtime";
19import * as Data from "effect/Data";23import * as Data from "effect/Data";
20import * as Predicate from "effect/Predicate";24import * as Predicate from "effect/Predicate";
2125
...@@ -25,11 +29,12 @@ export class BootstrapHttpError extends Data.TaggedError("BootstrapHttpError")<{...@@ -25,11 +29,12 @@ export class BootstrapHttpError extends Data.TaggedError("BootstrapHttpError")<{
25}> {}29}> {}
26const isBootstrapHttpError = (u: unknown): u is BootstrapHttpError =>30const isBootstrapHttpError = (u: unknown): u is BootstrapHttpError =>
27 Predicate.isTagged(u, "BootstrapHttpError");31 Predicate.isTagged(u, "BootstrapHttpError");
32const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError);
2833
29export interface ServerPairingLinkRecord {34export interface ServerPairingLinkRecord {
30 readonly id: string;35 readonly id: string;
31 readonly credential: string;36 readonly credential: string;
32 readonly role: "owner" | "client";37 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
33 readonly subject: string;38 readonly subject: string;
34 readonly label?: string;39 readonly label?: string;
35 readonly createdAt: string;40 readonly createdAt: string;
...@@ -39,8 +44,8 @@ export interface ServerPairingLinkRecord {...@@ -39,8 +44,8 @@ export interface ServerPairingLinkRecord {
39export interface ServerClientSessionRecord {44export interface ServerClientSessionRecord {
40 readonly sessionId: AuthSessionId;45 readonly sessionId: AuthSessionId;
41 readonly subject: string;46 readonly subject: string;
42 readonly role: "owner" | "client";47 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
43 readonly method: "browser-session-cookie" | "bearer-session-token";48 readonly method: "browser-session-cookie" | "bearer-access-token";
44 readonly client: AuthClientMetadata;49 readonly client: AuthClientMetadata;
45 readonly issuedAt: string;50 readonly issuedAt: string;
46 readonly expiresAt: string;51 readonly expiresAt: string;
...@@ -93,79 +98,97 @@ function getDesktopBootstrapCredential(): string | null {...@@ -93,79 +98,97 @@ function getDesktopBootstrapCredential(): string | null {
9398
94export async function fetchSessionState(): Promise<AuthSessionState> {99export async function fetchSessionState(): Promise<AuthSessionState> {
95 return retryTransientBootstrap(async () => {100 return retryTransientBootstrap(async () => {
96 const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/session"), {101 try {
97 credentials: "include",102 return await runPrimaryHttp(
98 });103 PrimaryEnvironmentHttpClient.pipe(
99 if (!response.ok) {104 Effect.flatMap((client) => client.auth.session({ headers: {} })),
105 ),
106 );
107 } catch (error) {
108 const status = readHttpApiStatus(error);
100 throw new BootstrapHttpError({109 throw new BootstrapHttpError({
101 message: `Failed to load server auth session state (${response.status}).`,110 message: `Failed to load server auth session state (${status ?? "unknown"}).`,
102 status: response.status,111 status: status ?? 500,
103 });112 });
104 }113 }
105 return (await response.json()) as AuthSessionState;
106 });114 });
107}115}
108116
109async function readErrorMessage(response: Response, fallbackMessage: string): Promise<string> {117function readHttpApiStatus(error: unknown): number | null {
110 const text = await response.text();118 if (isEnvironmentHttpCommonError(error)) {
111 return text || fallbackMessage;119 return readEnvironmentHttpErrorStatus(error);
120 }
121 return HttpClientError.isHttpClientError(error) && error.response !== undefined
122 ? error.response.status
123 : null;
112}124}
113125
114const INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES = new Set([126function readEnvironmentHttpErrorStatus(error: EnvironmentHttpCommonErrorType): number {
115 "Invalid bootstrap credential.",127 switch (error._tag) {
116 "Unknown bootstrap credential.",128 case "EnvironmentRequestInvalidError":
117]);129 return 400;
118130 case "EnvironmentAuthInvalidError":
119function parseBootstrapErrorMessage(message: string): string {131 return 401;
120 const trimmed = message.trim();132 case "EnvironmentScopeRequiredError":
121 if (trimmed.length === 0) {133 case "EnvironmentOperationForbiddenError":
122 return "";134 return 403;
135 case "EnvironmentInternalError":
136 return 500;
123 }137 }
138}
124139
125 try {140function readHttpApiErrorMessage(error: unknown, fallbackMessage: string): string {
126 const parsed = JSON.parse(trimmed) as {141 if (!isEnvironmentHttpCommonError(error)) {
127 error?: unknown;142 return fallbackMessage;
128 };143 }
129 if (typeof parsed.error === "string" && parsed.error.trim().length > 0) {144 switch (error._tag) {
130 return parsed.error.trim();145 case "EnvironmentAuthInvalidError":
131 }146 return error.reason === "missing_credential"
132 } catch {147 ? "Authentication required."
133 // Not JSON; fall back to plain text.148 : "Invalid bootstrap credential.";
149 case "EnvironmentRequestInvalidError":
150 return error.reason === "invalid_scope"
151 ? "Requested token scope is invalid."
152 : "Requested scope exceeds the bootstrap credential grant.";
153 case "EnvironmentScopeRequiredError":
154 return `The authenticated token is missing required scope: ${error.requiredScope}.`;
155 case "EnvironmentOperationForbiddenError":
156 return "This operation is not allowed for the current session.";
157 case "EnvironmentInternalError":
158 return fallbackMessage;
134 }159 }
135
136 return trimmed;
137}160}
138161
162const INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES = new Set([
163 "Invalid bootstrap credential.",
164 "Unknown bootstrap credential.",
165]);
166
139function toFriendlyBootstrapErrorMessage(status: number, message: string): string {167function toFriendlyBootstrapErrorMessage(status: number, message: string): string {
140 const parsedMessage = parseBootstrapErrorMessage(message);168 const trimmedMessage = message.trim();
141 if (status === 401 && INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES.has(parsedMessage)) {169 if (status === 401 && INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES.has(trimmedMessage)) {
142 return "Invalid pairing token. Check the token and try again.";170 return "Invalid pairing token. Check the token and try again.";
143 }171 }
144172
145 return parsedMessage;173 return trimmedMessage;
146}174}
147175
148async function exchangeBootstrapCredential(credential: string): Promise<AuthBootstrapResult> {176async function exchangeBootstrapCredential(credential: string): Promise<AuthBrowserSessionResult> {
149 return retryTransientBootstrap(async () => {177 return retryTransientBootstrap(async () => {
150 const payload: AuthBootstrapInput = { credential };178 try {
151 const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/bootstrap"), {179 return await runPrimaryHttp(
152 body: JSON.stringify(payload),180 PrimaryEnvironmentHttpClient.pipe(
153 credentials: "include",181 Effect.flatMap((client) => client.auth.browserSession({ payload: { credential } })),
154 headers: {182 ),
155 "content-type": "application/json",183 );
156 },184 } catch (error) {
157 method: "POST",185 const status = readHttpApiStatus(error) ?? 500;
158 });186 const message = toFriendlyBootstrapErrorMessage(status, readHttpApiErrorMessage(error, ""));
159
160 if (!response.ok) {
161 const message = toFriendlyBootstrapErrorMessage(response.status, await response.text());
162 throw new BootstrapHttpError({187 throw new BootstrapHttpError({
163 message: message || `Failed to bootstrap auth session (${response.status}).`,188 message: message || `Failed to bootstrap auth session (${status}).`,
164 status: response.status,189 status,
165 });190 });
166 }191 }
167
168 return (await response.json()) as AuthBootstrapResult;
169 });192 });
170}193}
171194
...@@ -266,57 +289,93 @@ export async function submitServerAuthCredential(credential: string): Promise<vo...@@ -266,57 +289,93 @@ export async function submitServerAuthCredential(credential: string): Promise<vo
266 stripPairingTokenFromUrl();289 stripPairingTokenFromUrl();
267}290}
268291
269export async function createServerPairingCredential(292export async function createServerPairingCredential(input?: {
270 label?: string,293 readonly label?: string;
271): Promise<AuthPairingCredentialResult> {294 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
272 const trimmedLabel = label?.trim();295}): Promise<AuthPairingCredentialResult> {
273 const payload: AuthCreatePairingCredentialInput = trimmedLabel ? { label: trimmedLabel } : {};296 const trimmedLabel = input?.label?.trim();
274 const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/pairing-token"), {297 try {
275 body: JSON.stringify(payload),298 return await runPrimaryHttp(
276 credentials: "include",299 PrimaryEnvironmentHttpClient.pipe(
277 headers: {300 Effect.flatMap((client) =>
278 "content-type": "application/json",301 client.auth.pairingCredential({
279 },302 headers: {},
280 method: "POST",303 payload: {
281 });304 ...(trimmedLabel ? { label: trimmedLabel } : {}),
282305 ...(input?.scopes ? { scopes: input.scopes } : {}),
283 if (!response.ok) {306 },
307 }),
308 ),
309 ),
310 );
311 } catch (error) {
284 throw new Error(312 throw new Error(
285 await readErrorMessage(response, `Failed to create pairing credential (${response.status}).`),313 readHttpApiErrorMessage(
314 error,
315 `Failed to create pairing credential (${readHttpApiStatus(error) ?? "unknown"}).`,
316 ),
317 { cause: error },
286 );318 );
287 }319 }
288
289 return (await response.json()) as AuthPairingCredentialResult;
290}320}
291321
292export async function listServerPairingLinks(): Promise<ReadonlyArray<ServerPairingLinkRecord>> {322export async function listServerPairingLinks(): Promise<ReadonlyArray<ServerPairingLinkRecord>> {
293 const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/pairing-links"), {323 try {
294 credentials: "include",324 const pairingLinks = await runPrimaryHttp(
295 });325 PrimaryEnvironmentHttpClient.pipe(
296326 Effect.flatMap((client) => client.auth.pairingLinks({ headers: {} })),
297 if (!response.ok) {327 ),
328 );
329 return pairingLinks.map((pairingLink) => {
330 const timestamps = {
331 createdAt: DateTime.formatIso(pairingLink.createdAt),
332 expiresAt: DateTime.formatIso(pairingLink.expiresAt),
333 };
334 if (pairingLink.label === undefined) {
335 return {
336 id: pairingLink.id,
337 credential: pairingLink.credential,
338 scopes: pairingLink.scopes,
339 subject: pairingLink.subject,
340 createdAt: timestamps.createdAt,
341 expiresAt: timestamps.expiresAt,
342 };
343 }
344 return {
345 id: pairingLink.id,
346 credential: pairingLink.credential,
347 scopes: pairingLink.scopes,
348 subject: pairingLink.subject,
349 label: pairingLink.label,
350 createdAt: timestamps.createdAt,
351 expiresAt: timestamps.expiresAt,
352 };
353 });
354 } catch (error) {
298 throw new Error(355 throw new Error(
299 await readErrorMessage(response, `Failed to load pairing links (${response.status}).`),356 readHttpApiErrorMessage(
357 error,
358 `Failed to load pairing links (${readHttpApiStatus(error) ?? "unknown"}).`,
359 ),
360 { cause: error },
300 );361 );
301 }362 }
302
303 return (await response.json()) as ReadonlyArray<ServerPairingLinkRecord>;
304}363}
305364
306export async function revokeServerPairingLink(id: string): Promise<void> {365export async function revokeServerPairingLink(id: string): Promise<void> {
307 const payload: AuthRevokePairingLinkInput = { id };366 try {
308 const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/pairing-links/revoke"), {367 await runPrimaryHttp(
309 body: JSON.stringify(payload),368 PrimaryEnvironmentHttpClient.pipe(
310 credentials: "include",369 Effect.flatMap((client) => client.auth.revokePairingLink({ headers: {}, payload: { id } })),
311 headers: {370 ),
312 "content-type": "application/json",371 );
313 },372 } catch (error) {
314 method: "POST",
315 });
316
317 if (!response.ok) {
318 throw new Error(373 throw new Error(
319 await readErrorMessage(response, `Failed to revoke pairing link (${response.status}).`),374 readHttpApiErrorMessage(
375 error,
376 `Failed to revoke pairing link (${readHttpApiStatus(error) ?? "unknown"}).`,
377 ),
378 { cause: error },
320 );379 );
321 }380 }
322}381}
...@@ -324,57 +383,75 @@ export async function revokeServerPairingLink(id: string): Promise<void> {...@@ -324,57 +383,75 @@ export async function revokeServerPairingLink(id: string): Promise<void> {
324export async function listServerClientSessions(): Promise<383export async function listServerClientSessions(): Promise<
325 ReadonlyArray<ServerClientSessionRecord>384 ReadonlyArray<ServerClientSessionRecord>
326> {385> {
327 const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/clients"), {386 try {
328 credentials: "include",387 const clientSessions = await runPrimaryHttp(
329 });388 PrimaryEnvironmentHttpClient.pipe(
330389 Effect.flatMap((client) => client.auth.clients({ headers: {} })),
331 if (!response.ok) {390 ),
391 );
392 return clientSessions.map((clientSession) => ({
393 sessionId: clientSession.sessionId,
394 subject: clientSession.subject,
395 scopes: clientSession.scopes,
396 method: clientSession.method,
397 client: clientSession.client,
398 issuedAt: DateTime.formatIso(clientSession.issuedAt),
399 expiresAt: DateTime.formatIso(clientSession.expiresAt),
400 lastConnectedAt:
401 clientSession.lastConnectedAt === null
402 ? null
403 : DateTime.formatIso(clientSession.lastConnectedAt),
404 connected: clientSession.connected,
405 current: clientSession.current,
406 }));
407 } catch (error) {
332 throw new Error(408 throw new Error(
333 await readErrorMessage(response, `Failed to load paired clients (${response.status}).`),409 readHttpApiErrorMessage(
410 error,
411 `Failed to load paired clients (${readHttpApiStatus(error) ?? "unknown"}).`,
412 ),
413 { cause: error },
334 );414 );
335 }415 }
336
337 return (await response.json()) as ReadonlyArray<ServerClientSessionRecord>;
338}416}
339417
340export async function revokeServerClientSession(sessionId: AuthSessionId): Promise<void> {418export async function revokeServerClientSession(sessionId: AuthSessionId): Promise<void> {
341 const payload: AuthRevokeClientSessionInput = { sessionId };419 try {
342 const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/clients/revoke"), {420 await runPrimaryHttp(
343 body: JSON.stringify(payload),421 PrimaryEnvironmentHttpClient.pipe(
344 credentials: "include",422 Effect.flatMap((client) =>
345 headers: {423 client.auth.revokeClient({ headers: {}, payload: { sessionId } }),
346 "content-type": "application/json",424 ),
347 },425 ),
348 method: "POST",426 );
349 });427 } catch (error) {
350
351 if (!response.ok) {
352 throw new Error(428 throw new Error(
353 await readErrorMessage(response, `Failed to revoke client session (${response.status}).`),429 readHttpApiErrorMessage(
430 error,
431 `Failed to revoke client session (${readHttpApiStatus(error) ?? "unknown"}).`,
432 ),
433 { cause: error },
354 );434 );
355 }435 }
356}436}
357437
358export async function revokeOtherServerClientSessions(): Promise<number> {438export async function revokeOtherServerClientSessions(): Promise<number> {
359 const response = await fetch(439 try {
360 resolvePrimaryEnvironmentHttpUrl("/api/auth/clients/revoke-others"),440 const result = await runPrimaryHttp(
361 {441 PrimaryEnvironmentHttpClient.pipe(
362 credentials: "include",442 Effect.flatMap((client) => client.auth.revokeOtherClients({ headers: {} })),
363 method: "POST",443 ),
364 },444 );
365 );445 return result.revokedCount;
366446 } catch (error) {
367 if (!response.ok) {
368 throw new Error(447 throw new Error(
369 await readErrorMessage(448 readHttpApiErrorMessage(
370 response,449 error,
371 `Failed to revoke other client sessions (${response.status}).`,450 `Failed to revoke other client sessions (${readHttpApiStatus(error) ?? "unknown"}).`,
372 ),451 ),
452 { cause: error },
373 );453 );
374 }454 }
375
376 const result = (await response.json()) as { revokedCount?: number };
377 return result.revokedCount ?? 0;
378}455}
379456
380export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGateState> {457export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGateState> {
apps/web/src/environments/primary/bootstrap.test.ts+41-33
...@@ -1,25 +1,18 @@...@@ -1,25 +1,18 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";1import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2import { EnvironmentId } from "@t3tools/contracts";2import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
34
4import {5import {
5 getPrimaryKnownEnvironment,6 getPrimaryKnownEnvironment,
7 resolvePrimaryEnvironmentHttpUrl,
6 resolveInitialPrimaryEnvironmentDescriptor,8 resolveInitialPrimaryEnvironmentDescriptor,
7 resetPrimaryEnvironmentDescriptorForTests,9 resetPrimaryEnvironmentDescriptorForTests,
8 writePrimaryEnvironmentDescriptor,10 writePrimaryEnvironmentDescriptor,
9} from ".";11} from ".";
1012import { installEnvironmentHttpTest } from "../../../test/environmentHttpTest";
11function jsonResponse(body: unknown, init?: ResponseInit) {
12 return new Response(JSON.stringify(body), {
13 headers: {
14 "content-type": "application/json",
15 },
16 status: 200,
17 ...init,
18 });
19}
2013
21const BASE_ENVIRONMENT = {14const BASE_ENVIRONMENT = {
22 environmentId: "environment-local",15 environmentId: EnvironmentId.make("environment-local"),
23 label: "Local environment",16 label: "Local environment",
24 platform: {17 platform: {
25 os: "darwin",18 os: "darwin",
...@@ -29,7 +22,17 @@ const BASE_ENVIRONMENT = {...@@ -29,7 +22,17 @@ const BASE_ENVIRONMENT = {
29 capabilities: {22 capabilities: {
30 repositoryIdentity: true,23 repositoryIdentity: true,
31 },24 },
32};25} satisfies ExecutionEnvironmentDescriptor;
26
27let disposeHttpTest: (() => Promise<void>) | undefined;
28
29async function installDescriptorApi() {
30 const testApi = await installEnvironmentHttpTest({
31 descriptor: () => Effect.succeed(BASE_ENVIRONMENT),
32 });
33 disposeHttpTest = testApi.dispose;
34 return testApi;
35}
3336
34function installTestBrowser(url: string) {37function installTestBrowser(url: string) {
35 vi.stubGlobal("window", {38 vi.stubGlobal("window", {
...@@ -47,7 +50,9 @@ describe("environmentBootstrap", () => {...@@ -47,7 +50,9 @@ describe("environmentBootstrap", () => {
47 installTestBrowser("http://localhost/");50 installTestBrowser("http://localhost/");
48 });51 });
4952
50 afterEach(() => {53 afterEach(async () => {
54 await disposeHttpTest?.();
55 disposeHttpTest = undefined;
51 resetPrimaryEnvironmentDescriptorForTests();56 resetPrimaryEnvironmentDescriptorForTests();
52 vi.unstubAllEnvs();57 vi.unstubAllEnvs();
53 vi.useRealTimers();58 vi.useRealTimers();
...@@ -88,35 +93,35 @@ describe("environmentBootstrap", () => {...@@ -88,35 +93,35 @@ describe("environmentBootstrap", () => {
88 });93 });
8994
90 it("reuses an in-flight descriptor bootstrap request", async () => {95 it("reuses an in-flight descriptor bootstrap request", async () => {
91 const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT));96 const testApi = await installDescriptorApi();
92 vi.stubGlobal("fetch", fetchMock);
9397
94 await Promise.all([98 await Promise.all([
95 resolveInitialPrimaryEnvironmentDescriptor(),99 resolveInitialPrimaryEnvironmentDescriptor(),
96 resolveInitialPrimaryEnvironmentDescriptor(),100 resolveInitialPrimaryEnvironmentDescriptor(),
97 ]);101 ]);
98102
99 expect(fetchMock).toHaveBeenCalledTimes(1);103 expect(testApi.calls.descriptor).toBe(1);
100 expect(fetchMock).toHaveBeenCalledWith("http://localhost/.well-known/t3/environment");
101 });104 });
102105
103 it("uses https descriptor urls when the primary environment uses wss", async () => {106 it("uses https descriptor urls when the primary environment uses wss", async () => {
104 const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT));
105 vi.stubGlobal("fetch", fetchMock);
106 vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com");107 vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com");
107 vi.stubEnv("VITE_WS_URL", "wss://remote.example.com");108 vi.stubEnv("VITE_WS_URL", "wss://remote.example.com");
109 await installDescriptorApi();
108110
109 await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT);111 await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT);
110 expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/.well-known/t3/environment");112 expect(resolvePrimaryEnvironmentHttpUrl("/.well-known/t3/environment")).toBe(
113 "https://remote.example.com/.well-known/t3/environment",
114 );
111 });115 });
112116
113 it("derives the websocket url when only VITE_HTTP_URL is configured", async () => {117 it("derives the websocket url when only VITE_HTTP_URL is configured", async () => {
114 const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT));
115 vi.stubGlobal("fetch", fetchMock);
116 vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com");118 vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com");
119 await installDescriptorApi();
117120
118 await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT);121 await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT);
119 expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/.well-known/t3/environment");122 expect(resolvePrimaryEnvironmentHttpUrl("/.well-known/t3/environment")).toBe(
123 "https://remote.example.com/.well-known/t3/environment",
124 );
120 expect(getPrimaryKnownEnvironment()?.target).toEqual({125 expect(getPrimaryKnownEnvironment()?.target).toEqual({
121 httpBaseUrl: "https://remote.example.com/",126 httpBaseUrl: "https://remote.example.com/",
122 wsBaseUrl: "wss://remote.example.com/",127 wsBaseUrl: "wss://remote.example.com/",
...@@ -124,12 +129,13 @@ describe("environmentBootstrap", () => {...@@ -124,12 +129,13 @@ describe("environmentBootstrap", () => {
124 });129 });
125130
126 it("derives the http url when only VITE_WS_URL is configured", async () => {131 it("derives the http url when only VITE_WS_URL is configured", async () => {
127 const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT));
128 vi.stubGlobal("fetch", fetchMock);
129 vi.stubEnv("VITE_WS_URL", "wss://remote.example.com");132 vi.stubEnv("VITE_WS_URL", "wss://remote.example.com");
133 await installDescriptorApi();
130134
131 await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT);135 await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT);
132 expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/.well-known/t3/environment");136 expect(resolvePrimaryEnvironmentHttpUrl("/.well-known/t3/environment")).toBe(
137 "https://remote.example.com/.well-known/t3/environment",
138 );
133 expect(getPrimaryKnownEnvironment()?.target).toEqual({139 expect(getPrimaryKnownEnvironment()?.target).toEqual({
134 httpBaseUrl: "https://remote.example.com/",140 httpBaseUrl: "https://remote.example.com/",
135 wsBaseUrl: "wss://remote.example.com/",141 wsBaseUrl: "wss://remote.example.com/",
...@@ -137,17 +143,16 @@ describe("environmentBootstrap", () => {...@@ -137,17 +143,16 @@ describe("environmentBootstrap", () => {
137 });143 });
138144
139 it("uses the current origin as the descriptor base for local dev environments", async () => {145 it("uses the current origin as the descriptor base for local dev environments", async () => {
140 const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT));
141 vi.stubGlobal("fetch", fetchMock);
142 installTestBrowser("http://localhost:5735/");146 installTestBrowser("http://localhost:5735/");
147 await installDescriptorApi();
143148
144 await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT);149 await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT);
145 expect(fetchMock).toHaveBeenCalledWith("http://localhost:5735/.well-known/t3/environment");150 expect(resolvePrimaryEnvironmentHttpUrl("/.well-known/t3/environment")).toBe(
151 "http://localhost:5735/.well-known/t3/environment",
152 );
146 });153 });
147154
148 it("uses the vite proxy for desktop-managed loopback descriptor requests during local dev", async () => {155 it("uses the vite proxy for desktop-managed loopback descriptor requests during local dev", async () => {
149 const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT));
150 vi.stubGlobal("fetch", fetchMock);
151 vi.stubEnv("VITE_DEV_SERVER_URL", "http://127.0.0.1:5733");156 vi.stubEnv("VITE_DEV_SERVER_URL", "http://127.0.0.1:5733");
152 vi.stubGlobal("window", {157 vi.stubGlobal("window", {
153 location: new URL("http://127.0.0.1:5733/"),158 location: new URL("http://127.0.0.1:5733/"),
...@@ -163,8 +168,11 @@ describe("environmentBootstrap", () => {...@@ -163,8 +168,11 @@ describe("environmentBootstrap", () => {
163 }),168 }),
164 },169 },
165 });170 });
171 await installDescriptorApi();
166172
167 await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT);173 await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT);
168 expect(fetchMock).toHaveBeenCalledWith("http://127.0.0.1:5733/.well-known/t3/environment");174 expect(resolvePrimaryEnvironmentHttpUrl("/.well-known/t3/environment")).toBe(
175 "http://127.0.0.1:5733/.well-known/t3/environment",
176 );
169 });177 });
170});178});
apps/web/src/environments/primary/context.ts+17-10
...@@ -4,13 +4,15 @@ import {...@@ -4,13 +4,15 @@ import {
4 type KnownEnvironment,4 type KnownEnvironment,
5} from "@t3tools/client-runtime";5} from "@t3tools/client-runtime";
6import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts";6import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
7import * as Effect from "effect/Effect";
8import { HttpClientError } from "effect/unstable/http";
7import { create } from "zustand";9import { create } from "zustand";
810
9import { BootstrapHttpError, retryTransientBootstrap } from "./auth";11import { BootstrapHttpError, retryTransientBootstrap } from "./auth";
12import { PrimaryEnvironmentHttpClient } from "./httpClient";
1013
11import { readPrimaryEnvironmentTarget, resolvePrimaryEnvironmentHttpUrl } from "./target";14import { runPrimaryHttp } from "../../lib/runtime";
1215import { readPrimaryEnvironmentTarget } from "./target";
13const SERVER_ENVIRONMENT_DESCRIPTOR_PATH = "/.well-known/t3/environment";
1416
15interface PrimaryEnvironmentBootstrapState {17interface PrimaryEnvironmentBootstrapState {
16 readonly descriptor: ExecutionEnvironmentDescriptor | null;18 readonly descriptor: ExecutionEnvironmentDescriptor | null;
...@@ -48,17 +50,22 @@ function createPrimaryKnownEnvironment(input: {...@@ -48,17 +50,22 @@ function createPrimaryKnownEnvironment(input: {
4850
49async function fetchPrimaryEnvironmentDescriptor(): Promise<ExecutionEnvironmentDescriptor> {51async function fetchPrimaryEnvironmentDescriptor(): Promise<ExecutionEnvironmentDescriptor> {
50 return retryTransientBootstrap(async () => {52 return retryTransientBootstrap(async () => {
51 const response = await fetch(53 let descriptor: ExecutionEnvironmentDescriptor;
52 resolvePrimaryEnvironmentHttpUrl(SERVER_ENVIRONMENT_DESCRIPTOR_PATH),54 try {
53 );55 descriptor = await runPrimaryHttp(
54 if (!response.ok) {56 PrimaryEnvironmentHttpClient.pipe(Effect.flatMap((client) => client.metadata.descriptor())),
57 );
58 } catch (error) {
59 const status =
60 HttpClientError.isHttpClientError(error) && error.response !== undefined
61 ? error.response.status
62 : 500;
55 throw new BootstrapHttpError({63 throw new BootstrapHttpError({
56 message: `Failed to load server environment descriptor (${response.status}).`,64 message: `Failed to load server environment descriptor (${status}).`,
57 status: response.status,65 status,
58 });66 });
59 }67 }
6068
61 const descriptor = (await response.json()) as ExecutionEnvironmentDescriptor;
62 writePrimaryEnvironmentDescriptor(descriptor);69 writePrimaryEnvironmentDescriptor(descriptor);
63 return descriptor;70 return descriptor;
64 });71 });
apps/web/src/environments/primary/httpClient.ts created+20
...@@ -0,0 +1,20 @@
1import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import { resolvePrimaryEnvironmentHttpUrl } from "./target";
7
8export type PrimaryEnvironmentHttpClientShape = Effect.Success<
9 ReturnType<typeof makeEnvironmentHttpApiClient>
10>;
11
12export class PrimaryEnvironmentHttpClient extends Context.Service<
13 PrimaryEnvironmentHttpClient,
14 PrimaryEnvironmentHttpClientShape
15>()("@t3tools/web/environments/primary/httpClient/PrimaryEnvironmentHttpClient") {}
16
17export const primaryEnvironmentHttpClientLive = Layer.effect(
18 PrimaryEnvironmentHttpClient,
19 Effect.suspend(() => makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/"))),
20);
apps/web/src/environments/primary/index.ts+2
...@@ -32,4 +32,6 @@ export {...@@ -32,4 +32,6 @@ export {
32 __resetServerAuthBootstrapForTests,32 __resetServerAuthBootstrapForTests,
33} from "./auth";33} from "./auth";
3434
35export { refreshPrimarySessionState, usePrimarySessionState } from "./sessionState";
36
35export { resolvePrimaryEnvironmentHttpUrl, isLoopbackHostname } from "./target";37export { resolvePrimaryEnvironmentHttpUrl, isLoopbackHostname } from "./target";
apps/web/src/environments/primary/sessionState.ts created+37
...@@ -0,0 +1,37 @@
1import { useAtomValue } from "@effect/atom-react";
2import * as Cause from "effect/Cause";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import { AsyncResult, Atom } from "effect/unstable/reactivity";
6import { useCallback } from "react";
7
8import { appAtomRegistry } from "../../rpc/atomRegistry";
9import { fetchSessionState } from "./auth";
10
11const primarySessionStateAtom = Atom.make(Effect.promise(fetchSessionState)).pipe(
12 Atom.swr({ staleTime: 5_000, revalidateOnMount: true }),
13 Atom.setIdleTTL(5 * 60_000),
14 Atom.withLabel("primary-environment:session"),
15);
16
17export function refreshPrimarySessionState(): void {
18 appAtomRegistry.refresh(primarySessionStateAtom);
19}
20
21export function usePrimarySessionState() {
22 const result = useAtomValue(primarySessionStateAtom);
23 const refresh = useCallback(() => {
24 refreshPrimarySessionState();
25 }, []);
26 let error: string | null = null;
27 if (result._tag === "Failure") {
28 const cause = Cause.squash(result.cause);
29 error = cause instanceof Error ? cause.message : "Could not read environment session.";
30 }
31 return {
32 data: Option.getOrNull(AsyncResult.value(result)),
33 error,
34 isPending: result.waiting,
35 refresh,
36 };
37}
apps/web/src/environments/runtime/catalog.ts+3-3
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { getKnownEnvironmentHttpBaseUrl } from "@t3tools/client-runtime";1import { getKnownEnvironmentHttpBaseUrl } from "@t3tools/client-runtime";
2import type {2import type {
3 AuthSessionRole,3 AuthEnvironmentScope,
4 EnvironmentId,4 EnvironmentId,
5 ExecutionEnvironmentDescriptor,5 ExecutionEnvironmentDescriptor,
6 PersistedSavedEnvironmentRecord,6 PersistedSavedEnvironmentRecord,
...@@ -272,7 +272,7 @@ export interface SavedEnvironmentRuntimeState {...@@ -272,7 +272,7 @@ export interface SavedEnvironmentRuntimeState {
272 readonly authState: SavedEnvironmentAuthState;272 readonly authState: SavedEnvironmentAuthState;
273 readonly lastError: string | null;273 readonly lastError: string | null;
274 readonly lastErrorAt: string | null;274 readonly lastErrorAt: string | null;
275 readonly role: AuthSessionRole | null;275 readonly scopes: ReadonlyArray<AuthEnvironmentScope> | null;
276 readonly descriptor: ExecutionEnvironmentDescriptor | null;276 readonly descriptor: ExecutionEnvironmentDescriptor | null;
277 readonly serverConfig: ServerConfig | null;277 readonly serverConfig: ServerConfig | null;
278 readonly connectedAt: string | null;278 readonly connectedAt: string | null;
...@@ -295,7 +295,7 @@ const DEFAULT_SAVED_ENVIRONMENT_RUNTIME_STATE: SavedEnvironmentRuntimeState = Ob...@@ -295,7 +295,7 @@ const DEFAULT_SAVED_ENVIRONMENT_RUNTIME_STATE: SavedEnvironmentRuntimeState = Ob
295 authState: "unknown",295 authState: "unknown",
296 lastError: null,296 lastError: null,
297 lastErrorAt: null,297 lastErrorAt: null,
298 role: null,298 scopes: null,
299 descriptor: null,299 descriptor: null,
300 serverConfig: null,300 serverConfig: null,
301 connectedAt: null,301 connectedAt: null,
apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts+30-31
...@@ -1,13 +1,15 @@...@@ -1,13 +1,15 @@
1import { EnvironmentId } from "@t3tools/contracts";1import { EnvironmentAuthInvalidError, EnvironmentId } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";3import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
34
5const decodeEnvironmentAuthInvalidError = Schema.decodeUnknownSync(EnvironmentAuthInvalidError);
6
4let mockSavedRecords: Array<Record<string, unknown>> = [];7let mockSavedRecords: Array<Record<string, unknown>> = [];
58
6const mockResolveRemotePairingTarget = vi.fn();9const mockResolveRemotePairingTarget = vi.fn();
7const mockFetchRemoteEnvironmentDescriptor = vi.fn();10const mockFetchRemoteEnvironmentDescriptor = vi.fn();
8const mockBootstrapRemoteBearerSession = vi.fn();11const mockBootstrapRemoteBearerSession = vi.fn();
9const mockFetchRemoteSessionState = vi.fn();12const mockFetchRemoteSessionState = vi.fn();
10const mockIsRemoteEnvironmentAuthHttpError = vi.fn((_: unknown) => false);
11const mockResolveRemoteWebSocketConnectionUrl = vi.fn();13const mockResolveRemoteWebSocketConnectionUrl = vi.fn();
12const mockRemoteHttpRunPromise = vi.fn((effect: Promise<unknown>) => effect);14const mockRemoteHttpRunPromise = vi.fn((effect: Promise<unknown>) => effect);
13const mockBootstrapSshBearerSession = vi.fn();15const mockBootstrapSshBearerSession = vi.fn();
...@@ -128,7 +130,6 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => {...@@ -128,7 +130,6 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => {
128 })),130 })),
129 fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor,131 fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor,
130 fetchRemoteSessionState: mockFetchRemoteSessionState,132 fetchRemoteSessionState: mockFetchRemoteSessionState,
131 isRemoteEnvironmentAuthHttpError: mockIsRemoteEnvironmentAuthHttpError,
132 resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl,133 resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl,
133 };134 };
134});135});
...@@ -153,7 +154,7 @@ describe("addSavedEnvironment", () => {...@@ -153,7 +154,7 @@ describe("addSavedEnvironment", () => {
153 fetchSshEnvironmentDescriptor: mockFetchSshEnvironmentDescriptor,154 fetchSshEnvironmentDescriptor: mockFetchSshEnvironmentDescriptor,
154 bootstrapSshBearerSession: mockBootstrapSshBearerSession,155 bootstrapSshBearerSession: mockBootstrapSshBearerSession,
155 fetchSshSessionState: mockFetchSshSessionState,156 fetchSshSessionState: mockFetchSshSessionState,
156 issueSshWebSocketToken: vi.fn(),157 issueSshWebSocketTicket: vi.fn(),
157 },158 },
158 });159 });
159 mockResolveRemotePairingTarget.mockImplementation(160 mockResolveRemotePairingTarget.mockImplementation(
...@@ -176,24 +177,23 @@ describe("addSavedEnvironment", () => {...@@ -176,24 +177,23 @@ describe("addSavedEnvironment", () => {
176 label: "Remote environment",177 label: "Remote environment",
177 });178 });
178 mockBootstrapRemoteBearerSession.mockResolvedValue({179 mockBootstrapRemoteBearerSession.mockResolvedValue({
179 sessionToken: "bearer-token",180 access_token: "bearer-token",
180 role: "owner",181 scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read",
181 });182 });
182 mockFetchRemoteSessionState.mockResolvedValue({183 mockFetchRemoteSessionState.mockResolvedValue({
183 authenticated: true,184 authenticated: true,
184 role: "owner",185 scopes: ["orchestration:read", "access:write"],
185 });186 });
186 mockIsRemoteEnvironmentAuthHttpError.mockReturnValue(false);
187 mockResolveRemoteWebSocketConnectionUrl.mockResolvedValue(187 mockResolveRemoteWebSocketConnectionUrl.mockResolvedValue(
188 "wss://remote.example.com/?wsToken=remote-token",188 "wss://remote.example.com/?wsTicket=remote-token",
189 );189 );
190 mockFetchSshEnvironmentDescriptor.mockResolvedValue({190 mockFetchSshEnvironmentDescriptor.mockResolvedValue({
191 environmentId: EnvironmentId.make("environment-1"),191 environmentId: EnvironmentId.make("environment-1"),
192 label: "Remote environment",192 label: "Remote environment",
193 });193 });
194 mockBootstrapSshBearerSession.mockResolvedValue({194 mockBootstrapSshBearerSession.mockResolvedValue({
195 sessionToken: "ssh-bearer-token",195 access_token: "ssh-bearer-token",
196 role: "owner",196 scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read",
197 });197 });
198 mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined);198 mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined);
199 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false);199 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false);
...@@ -202,7 +202,7 @@ describe("addSavedEnvironment", () => {...@@ -202,7 +202,7 @@ describe("addSavedEnvironment", () => {
202 mockRemoveSavedEnvironmentBearerToken.mockResolvedValue(undefined);202 mockRemoveSavedEnvironmentBearerToken.mockResolvedValue(undefined);
203 mockFetchSshSessionState.mockResolvedValue({203 mockFetchSshSessionState.mockResolvedValue({
204 authenticated: true,204 authenticated: true,
205 role: "owner",205 scopes: ["orchestration:read", "access:write"],
206 });206 });
207 mockCreateEnvironmentConnection.mockImplementation(207 mockCreateEnvironmentConnection.mockImplementation(
208 (input: { knownEnvironment: { environmentId: EnvironmentId }; client: unknown }) => ({208 (input: { knownEnvironment: { environmentId: EnvironmentId }; client: unknown }) => ({
...@@ -381,18 +381,18 @@ describe("addSavedEnvironment", () => {...@@ -381,18 +381,18 @@ describe("addSavedEnvironment", () => {
381 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true);381 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true);
382 mockBootstrapSshBearerSession382 mockBootstrapSshBearerSession
383 .mockResolvedValueOnce({383 .mockResolvedValueOnce({
384 sessionToken: "ssh-bearer-token",384 access_token: "ssh-bearer-token",
385 role: "owner",385 scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read",
386 })386 })
387 .mockResolvedValueOnce({387 .mockResolvedValueOnce({
388 sessionToken: "ssh-bearer-token-2",388 access_token: "ssh-bearer-token-2",
389 role: "owner",389 scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read",
390 });390 });
391 mockFetchSshSessionState391 mockFetchSshSessionState
392 .mockRejectedValueOnce(new Error("[ssh_http:401] Unauthorized"))392 .mockRejectedValueOnce(new Error("[ssh_http:401] Unauthorized"))
393 .mockResolvedValueOnce({393 .mockResolvedValueOnce({
394 authenticated: true,394 authenticated: true,
395 role: "owner",395 scopes: ["orchestration:read", "access:write"],
396 });396 });
397397
398 const { connectDesktopSshEnvironment, resetEnvironmentServiceForTests } =398 const { connectDesktopSshEnvironment, resetEnvironmentServiceForTests } =
...@@ -418,14 +418,13 @@ describe("addSavedEnvironment", () => {...@@ -418,14 +418,13 @@ describe("addSavedEnvironment", () => {
418418
419 it("does not attempt desktop ssh bearer recovery for non-ssh saved environments", async () => {419 it("does not attempt desktop ssh bearer recovery for non-ssh saved environments", async () => {
420 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true);420 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true);
421 const authError = {421 const authError = decodeEnvironmentAuthInvalidError({
422 status: 401,422 _tag: "EnvironmentAuthInvalidError",
423 message: "Unauthorized",423 code: "auth_invalid",
424 };424 reason: "invalid_credential",
425 traceId: "trace-auth-test",
426 });
425 mockFetchRemoteSessionState.mockRejectedValueOnce(authError);427 mockFetchRemoteSessionState.mockRejectedValueOnce(authError);
426 mockIsRemoteEnvironmentAuthHttpError.mockImplementation(
427 (error: unknown) => error === authError,
428 );
429428
430 const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service");429 const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service");
431430
...@@ -450,18 +449,18 @@ describe("addSavedEnvironment", () => {...@@ -450,18 +449,18 @@ describe("addSavedEnvironment", () => {
450 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true);449 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true);
451 mockBootstrapSshBearerSession450 mockBootstrapSshBearerSession
452 .mockResolvedValueOnce({451 .mockResolvedValueOnce({
453 sessionToken: "ssh-bearer-token",452 access_token: "ssh-bearer-token",
454 role: "owner",453 scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read",
455 })454 })
456 .mockResolvedValueOnce({455 .mockResolvedValueOnce({
457 sessionToken: "ssh-bearer-token-2",456 access_token: "ssh-bearer-token-2",
458 role: "owner",457 scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read",
459 });458 });
460 mockFetchSshSessionState459 mockFetchSshSessionState
461 .mockRejectedValueOnce(new Error("[ssh_http:401] Unauthorized"))460 .mockRejectedValueOnce(new Error("[ssh_http:401] Unauthorized"))
462 .mockResolvedValueOnce({461 .mockResolvedValueOnce({
463 authenticated: true,462 authenticated: true,
464 role: "owner",463 scopes: ["orchestration:read", "access:write"],
465 });464 });
466465
467 const createdConnections: Array<{466 const createdConnections: Array<{
...@@ -745,7 +744,7 @@ describe("addSavedEnvironment", () => {...@@ -745,7 +744,7 @@ describe("addSavedEnvironment", () => {
745 );744 );
746 let resolveSessionState!: (value: {745 let resolveSessionState!: (value: {
747 readonly authenticated: true;746 readonly authenticated: true;
748 readonly role: "owner";747 readonly scopes: ReadonlyArray<"orchestration:read" | "access:write">;
749 }) => void;748 }) => void;
750 mockFetchRemoteSessionState.mockReturnValue(749 mockFetchRemoteSessionState.mockReturnValue(
751 new Promise((resolve) => {750 new Promise((resolve) => {
...@@ -768,7 +767,7 @@ describe("addSavedEnvironment", () => {...@@ -768,7 +767,7 @@ describe("addSavedEnvironment", () => {
768 await disconnectSavedEnvironment(EnvironmentId.make("environment-1"));767 await disconnectSavedEnvironment(EnvironmentId.make("environment-1"));
769 resolveSessionState({768 resolveSessionState({
770 authenticated: true,769 authenticated: true,
771 role: "owner",770 scopes: ["orchestration:read", "access:write"],
772 });771 });
773 await expect(reconnectPromise).resolves.toBeUndefined();772 await expect(reconnectPromise).resolves.toBeUndefined();
774773
apps/web/src/environments/runtime/service.savedEnvironments.test.ts+1-1
...@@ -222,7 +222,7 @@ describe("saved environment startup", () => {...@@ -222,7 +222,7 @@ describe("saved environment startup", () => {
222222
223 mockFetchRemoteSessionState.mockResolvedValue({223 mockFetchRemoteSessionState.mockResolvedValue({
224 authenticated: true,224 authenticated: true,
225 role: "owner",225 scopes: ["orchestration:read", "access:write"],
226 });226 });
227 mockGetSavedEnvironmentRecord.mockImplementation((environmentId: EnvironmentId) =>227 mockGetSavedEnvironmentRecord.mockImplementation((environmentId: EnvironmentId) =>
228 environmentId === savedRecord.environmentId ? savedRecord : null,228 environmentId === savedRecord.environmentId ? savedRecord : null,
apps/web/src/environments/runtime/service.threadSubscriptions.test.ts+1-2
...@@ -153,7 +153,6 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => {...@@ -153,7 +153,6 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => {
153 ...actual,153 ...actual,
154 createWsRpcClient: vi.fn(() => stubWsClient),154 createWsRpcClient: vi.fn(() => stubWsClient),
155 fetchRemoteSessionState: mockFetchRemoteSessionState,155 fetchRemoteSessionState: mockFetchRemoteSessionState,
156 isRemoteEnvironmentAuthHttpError: vi.fn(() => false),
157 resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl,156 resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl,
158 };157 };
159});158});
...@@ -304,7 +303,7 @@ describe("retainThreadDetailSubscription", () => {...@@ -304,7 +303,7 @@ describe("retainThreadDetailSubscription", () => {
304 mockReadSavedEnvironmentBearerToken.mockResolvedValue(null);303 mockReadSavedEnvironmentBearerToken.mockResolvedValue(null);
305 mockFetchRemoteSessionState.mockResolvedValue({304 mockFetchRemoteSessionState.mockResolvedValue({
306 authenticated: true,305 authenticated: true,
307 role: "client",306 scopes: ["orchestration:read"],
308 });307 });
309 mockConnectionReconnects.length = 0;308 mockConnectionReconnects.length = 0;
310 });309 });
apps/web/src/environments/runtime/service.ts+31-22
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import {1import {
2 type AuthSessionRole,2 AuthEnvironmentScope,
3 AuthStandardClientScopes,
3 type DesktopSshEnvironmentBootstrap,4 type DesktopSshEnvironmentBootstrap,
4 type DesktopSshEnvironmentTarget,5 type DesktopSshEnvironmentTarget,
5 type EnvironmentId,6 type EnvironmentId,
...@@ -7,6 +8,7 @@ import {...@@ -7,6 +8,7 @@ import {
7 type OrchestrationShellSnapshot,8 type OrchestrationShellSnapshot,
8 type OrchestrationShellStreamEvent,9 type OrchestrationShellStreamEvent,
9 type ServerConfig,10 type ServerConfig,
11 EnvironmentAuthInvalidError,
10 ThreadId,12 ThreadId,
11} from "@t3tools/contracts";13} from "@t3tools/contracts";
12import {14import {
...@@ -15,12 +17,12 @@ import {...@@ -15,12 +17,12 @@ import {
15 bootstrapRemoteBearerSession,17 bootstrapRemoteBearerSession,
16 fetchRemoteEnvironmentDescriptor,18 fetchRemoteEnvironmentDescriptor,
17 fetchRemoteSessionState,19 fetchRemoteSessionState,
18 isRemoteEnvironmentAuthHttpError,
19 resolveRemoteWebSocketConnectionUrl,20 resolveRemoteWebSocketConnectionUrl,
20} from "@t3tools/client-runtime";21} from "@t3tools/client-runtime";
2122
22import { type QueryClient } from "@tanstack/react-query";23import { type QueryClient } from "@tanstack/react-query";
23import { Throttler } from "@tanstack/react-pacer";24import { Throttler } from "@tanstack/react-pacer";
25import * as Schema from "effect/Schema";
24import {26import {
25 createKnownEnvironment,27 createKnownEnvironment,
26 getKnownEnvironmentWsBaseUrl,28 getKnownEnvironmentWsBaseUrl,
...@@ -76,6 +78,8 @@ import {...@@ -76,6 +78,8 @@ import {
76 deriveLogicalProjectKeyFromSettings,78 deriveLogicalProjectKeyFromSettings,
77 derivePhysicalProjectKey,79 derivePhysicalProjectKey,
78} from "../../logicalProject";80} from "../../logicalProject";
81
82const decodeIssuedBearerScopes = Schema.decodeUnknownSync(Schema.Array(AuthEnvironmentScope));
79import { getClientSettings } from "~/hooks/useSettings";83import { getClientSettings } from "~/hooks/useSettings";
80import { subscribeTerminalMetadata, terminalSessionManager } from "../../terminalSessionState";84import { subscribeTerminalMetadata, terminalSessionManager } from "../../terminalSessionState";
81import { resetWsReconnectBackoff } from "~/rpc/wsConnectionState";85import { resetWsReconnectBackoff } from "~/rpc/wsConnectionState";
...@@ -99,6 +103,7 @@ type ThreadDetailSubscriptionEntry = {...@@ -99,6 +103,7 @@ type ThreadDetailSubscriptionEntry = {
99};103};
100104
101const environmentConnections = new Map<EnvironmentId, EnvironmentConnection>();105const environmentConnections = new Map<EnvironmentId, EnvironmentConnection>();
106const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError);
102107
103function isSavedEnvironmentConnectionCancelledError(108function isSavedEnvironmentConnectionCancelledError(
104 error: unknown,109 error: unknown,
...@@ -738,6 +743,10 @@ async function bootstrapDesktopSshBearerSession(httpBaseUrl: string, credential:...@@ -738,6 +743,10 @@ async function bootstrapDesktopSshBearerSession(httpBaseUrl: string, credential:
738 return await getDesktopSshBridge().bootstrapSshBearerSession(httpBaseUrl, credential);743 return await getDesktopSshBridge().bootstrapSshBearerSession(httpBaseUrl, credential);
739}744}
740745
746function readIssuedBearerScopes(scope: string): ReadonlyArray<AuthEnvironmentScope> {
747 return decodeIssuedBearerScopes(scope.split(" "));
748}
749
741async function fetchDesktopSshSessionState(httpBaseUrl: string, bearerToken: string) {750async function fetchDesktopSshSessionState(httpBaseUrl: string, bearerToken: string) {
742 return await getDesktopSshBridge().fetchSshSessionState(httpBaseUrl, bearerToken);751 return await getDesktopSshBridge().fetchSshSessionState(httpBaseUrl, bearerToken);
743}752}
...@@ -747,9 +756,9 @@ async function resolveDesktopSshWebSocketConnectionUrl(...@@ -747,9 +756,9 @@ async function resolveDesktopSshWebSocketConnectionUrl(
747 httpBaseUrl: string,756 httpBaseUrl: string,
748 bearerToken: string,757 bearerToken: string,
749) {758) {
750 const issued = await getDesktopSshBridge().issueSshWebSocketToken(httpBaseUrl, bearerToken);759 const issued = await getDesktopSshBridge().issueSshWebSocketTicket(httpBaseUrl, bearerToken);
751 const url = new URL(wsBaseUrl, window.location.origin);760 const url = new URL(wsBaseUrl, window.location.origin);
752 url.searchParams.set("wsToken", issued.token);761 url.searchParams.set("wsTicket", issued.ticket);
753 return url.toString();762 return url.toString();
754}763}
755764
...@@ -799,7 +808,7 @@ async function prepareSavedEnvironmentRecordForConnection(...@@ -799,7 +808,7 @@ async function prepareSavedEnvironmentRecordForConnection(
799async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Promise<{808async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Promise<{
800 readonly record: SavedEnvironmentRecord;809 readonly record: SavedEnvironmentRecord;
801 readonly bearerToken: string;810 readonly bearerToken: string;
802 readonly role: AuthSessionRole | null;811 readonly scopes: ReadonlyArray<AuthEnvironmentScope> | null;
803}> {812}> {
804 const registrySnapshot = snapshotSavedEnvironmentRegistry([record.environmentId]);813 const registrySnapshot = snapshotSavedEnvironmentRegistry([record.environmentId]);
805 const prepared = await prepareSavedEnvironmentRecordForConnection(record, {814 const prepared = await prepareSavedEnvironmentRecordForConnection(record, {
...@@ -827,7 +836,7 @@ async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Pro...@@ -827,7 +836,7 @@ async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Pro
827 });836 });
828 const didPersistBearerToken = await writeSavedEnvironmentBearerToken(837 const didPersistBearerToken = await writeSavedEnvironmentBearerToken(
829 prepared.record.environmentId,838 prepared.record.environmentId,
830 bearerSession.sessionToken,839 bearerSession.access_token,
831 );840 );
832 if (!didPersistBearerToken) {841 if (!didPersistBearerToken) {
833 await persistSavedEnvironmentRegistryRollback(registrySnapshot);842 await persistSavedEnvironmentRegistryRollback(registrySnapshot);
...@@ -836,8 +845,8 @@ async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Pro...@@ -836,8 +845,8 @@ async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Pro
836845
837 return {846 return {
838 record: prepared.record,847 record: prepared.record,
839 bearerToken: bearerSession.sessionToken,848 bearerToken: bearerSession.access_token,
840 role: bearerSession.role ?? null,849 scopes: readIssuedBearerScopes(bearerSession.scope),
841 };850 };
842}851}
843852
...@@ -1214,7 +1223,7 @@ async function refreshSavedEnvironmentMetadata(...@@ -1214,7 +1223,7 @@ async function refreshSavedEnvironmentMetadata(
1214 environmentId: EnvironmentId,1223 environmentId: EnvironmentId,
1215 bearerToken: string,1224 bearerToken: string,
1216 client: WsRpcClient,1225 client: WsRpcClient,
1217 roleHint?: AuthSessionRole | null,1226 scopeHint?: ReadonlyArray<AuthEnvironmentScope> | null,
1218 configHint?: ServerConfig | null,1227 configHint?: ServerConfig | null,
1219): Promise<void> {1228): Promise<void> {
1220 const record = getSavedEnvironmentRecord(environmentId);1229 const record = getSavedEnvironmentRecord(environmentId);
...@@ -1238,7 +1247,7 @@ async function refreshSavedEnvironmentMetadata(...@@ -1238,7 +1247,7 @@ async function refreshSavedEnvironmentMetadata(
1238 authState: sessionState.authenticated ? "authenticated" : "requires-auth",1247 authState: sessionState.authenticated ? "authenticated" : "requires-auth",
1239 descriptor: serverConfig.environment,1248 descriptor: serverConfig.environment,
1240 serverConfig,1249 serverConfig,
1241 role: sessionState.authenticated ? (sessionState.role ?? roleHint ?? null) : null,1250 scopes: sessionState.authenticated ? (sessionState.scopes ?? scopeHint ?? null) : null,
1242 });1251 });
1243 useSavedEnvironmentRegistryStore1252 useSavedEnvironmentRegistryStore
1244 .getState()1253 .getState()
...@@ -1311,7 +1320,7 @@ async function ensureSavedEnvironmentConnection(...@@ -1311,7 +1320,7 @@ async function ensureSavedEnvironmentConnection(
1311 options?: {1320 options?: {
1312 readonly client?: WsRpcClient;1321 readonly client?: WsRpcClient;
1313 readonly bearerToken?: string;1322 readonly bearerToken?: string;
1314 readonly role?: AuthSessionRole | null;1323 readonly scopes?: ReadonlyArray<AuthEnvironmentScope> | null;
1315 readonly serverConfig?: ServerConfig | null;1324 readonly serverConfig?: ServerConfig | null;
1316 },1325 },
1317): Promise<EnvironmentConnection> {1326): Promise<EnvironmentConnection> {
...@@ -1330,7 +1339,7 @@ async function ensureSavedEnvironmentConnection(...@@ -1330,7 +1339,7 @@ async function ensureSavedEnvironmentConnection(
1330 isCurrent: attempt.isCurrent,1339 isCurrent: attempt.isCurrent,
1331 promise: Promise.resolve().then(async () => {1340 promise: Promise.resolve().then(async () => {
1332 let activeRecord = record;1341 let activeRecord = record;
1333 let roleHint = options?.role ?? null;1342 let scopeHint = options?.scopes ?? null;
1334 let bearerToken =1343 let bearerToken =
1335 options?.bearerToken ?? (await readSavedEnvironmentBearerToken(record.environmentId));1344 options?.bearerToken ?? (await readSavedEnvironmentBearerToken(record.environmentId));
1336 if (!bearerToken) {1345 if (!bearerToken) {
...@@ -1338,11 +1347,11 @@ async function ensureSavedEnvironmentConnection(...@@ -1338,11 +1347,11 @@ async function ensureSavedEnvironmentConnection(
1338 const issued = await issueDesktopSshBearerSession(record);1347 const issued = await issueDesktopSshBearerSession(record);
1339 activeRecord = issued.record;1348 activeRecord = issued.record;
1340 bearerToken = issued.bearerToken;1349 bearerToken = issued.bearerToken;
1341 roleHint = issued.role;1350 scopeHint = [...AuthStandardClientScopes];
1342 } else {1351 } else {
1343 useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, {1352 useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, {
1344 authState: "requires-auth",1353 authState: "requires-auth",
1345 role: null,1354 scopes: null,
1346 connectionState: "disconnected",1355 connectionState: "disconnected",
1347 lastError: "Saved environment is missing its saved credential. Pair it again.",1356 lastError: "Saved environment is missing its saved credential. Pair it again.",
1348 lastErrorAt: isoNow(),1357 lastErrorAt: isoNow(),
...@@ -1409,13 +1418,13 @@ async function ensureSavedEnvironmentConnection(...@@ -1409,13 +1418,13 @@ async function ensureSavedEnvironmentConnection(
1409 activeRecord.environmentId,1418 activeRecord.environmentId,
1410 activeBearerToken,1419 activeBearerToken,
1411 client,1420 client,
1412 roleHint,1421 scopeHint,
1413 initialServerConfig,1422 initialServerConfig,
1414 );1423 );
1415 } catch (error) {1424 } catch (error) {
1416 const isAuthError = activeRecord.desktopSsh1425 const isAuthError = activeRecord.desktopSsh
1417 ? isSshHttpAuthError(error, 401)1426 ? isSshHttpAuthError(error, 401)
1418 : isRemoteEnvironmentAuthHttpError(error) && error.status === 401;1427 : isEnvironmentAuthInvalidError(error);
1419 if (!isAuthError) {1428 if (!isAuthError) {
1420 throw error;1429 throw error;
1421 }1430 }
...@@ -1429,12 +1438,12 @@ async function ensureSavedEnvironmentConnection(...@@ -1429,12 +1438,12 @@ async function ensureSavedEnvironmentConnection(
1429 const issued = await issueDesktopSshBearerSession(activeRecord);1438 const issued = await issueDesktopSshBearerSession(activeRecord);
1430 activeRecord = issued.record;1439 activeRecord = issued.record;
1431 bearerToken = issued.bearerToken;1440 bearerToken = issued.bearerToken;
1432 roleHint = issued.role;1441 scopeHint = [...AuthStandardClientScopes];
1433 await connection.dispose().catch(() => undefined);1442 await connection.dispose().catch(() => undefined);
1434 pendingSavedEnvironmentConnections.delete(activeRecord.environmentId);1443 pendingSavedEnvironmentConnections.delete(activeRecord.environmentId);
1435 return await ensureSavedEnvironmentConnection(activeRecord, {1444 return await ensureSavedEnvironmentConnection(activeRecord, {
1436 bearerToken,1445 bearerToken,
1437 role: roleHint,1446 scopes: scopeHint,
1438 serverConfig: options?.serverConfig ?? null,1447 serverConfig: options?.serverConfig ?? null,
1439 });1448 });
1440 }1449 }
...@@ -1638,7 +1647,7 @@ export async function reconnectSavedEnvironment(environmentId: EnvironmentId): P...@@ -1638,7 +1647,7 @@ export async function reconnectSavedEnvironment(environmentId: EnvironmentId): P
1638 await removeConnection(environmentId).catch(() => false);1647 await removeConnection(environmentId).catch(() => false);
1639 await ensureSavedEnvironmentConnection(issued.record, {1648 await ensureSavedEnvironmentConnection(issued.record, {
1640 bearerToken: issued.bearerToken,1649 bearerToken: issued.bearerToken,
1641 role: issued.role,1650 scopes: [...AuthStandardClientScopes],
1642 });1651 });
1643 return;1652 return;
1644 } catch (recoveryError) {1653 } catch (recoveryError) {
...@@ -1714,7 +1723,7 @@ export async function addSavedEnvironment(input: {...@@ -1714,7 +1723,7 @@ export async function addSavedEnvironment(input: {
1714 await persistSavedEnvironmentRecord(record);1723 await persistSavedEnvironmentRecord(record);
1715 const didPersistBearerToken = await writeSavedEnvironmentBearerToken(1724 const didPersistBearerToken = await writeSavedEnvironmentBearerToken(
1716 environmentId,1725 environmentId,
1717 bearerSession.sessionToken,1726 bearerSession.access_token,
1718 );1727 );
1719 if (!didPersistBearerToken) {1728 if (!didPersistBearerToken) {
1720 await persistSavedEnvironmentRegistryRollback(registrySnapshot);1729 await persistSavedEnvironmentRegistryRollback(registrySnapshot);
...@@ -1726,8 +1735,8 @@ export async function addSavedEnvironment(input: {...@@ -1726,8 +1735,8 @@ export async function addSavedEnvironment(input: {
1726 }1735 }
1727 await removeConnection(environmentId).catch(() => false);1736 await removeConnection(environmentId).catch(() => false);
1728 await ensureSavedEnvironmentConnection(record, {1737 await ensureSavedEnvironmentConnection(record, {
1729 bearerToken: bearerSession.sessionToken,1738 bearerToken: bearerSession.access_token,
1730 role: bearerSession.role,1739 scopes: readIssuedBearerScopes(bearerSession.scope),
1731 });1740 });
1732 return record;1741 return record;
1733}1742}
apps/web/src/lib/runtime.ts+34
...@@ -1,5 +1,39 @@...@@ -1,5 +1,39 @@
1import * as ManagedRuntime from "effect/ManagedRuntime";1import * as ManagedRuntime from "effect/ManagedRuntime";
2import type * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import { FetchHttpClient } from "effect/unstable/http";
25
3import { remoteHttpClientLayer } from "@t3tools/client-runtime";6import { remoteHttpClientLayer } from "@t3tools/client-runtime";
7import {
8 PrimaryEnvironmentHttpClient,
9 primaryEnvironmentHttpClientLive,
10} from "../environments/primary/httpClient";
411
5export const remoteHttpRuntime = ManagedRuntime.make(remoteHttpClientLayer(globalThis.fetch));12export const remoteHttpRuntime = ManagedRuntime.make(remoteHttpClientLayer(globalThis.fetch));
13
14const primaryHttpRuntime = ManagedRuntime.make(
15 primaryEnvironmentHttpClientLive.pipe(
16 Layer.provide(
17 Layer.mergeAll(
18 remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)),
19 Layer.succeed(FetchHttpClient.RequestInit, { credentials: "include" }),
20 ),
21 ),
22 ),
23);
24
25export type PrimaryHttpEffectRunner = <A, E>(
26 effect: Effect.Effect<A, E, PrimaryEnvironmentHttpClient>,
27) => Promise<A>;
28
29const livePrimaryHttpRunner: PrimaryHttpEffectRunner = (effect) =>
30 primaryHttpRuntime.runPromise(effect);
31
32let primaryHttpRunner = livePrimaryHttpRunner;
33
34export const runPrimaryHttp = <A, E>(effect: Effect.Effect<A, E, PrimaryEnvironmentHttpClient>) =>
35 primaryHttpRunner(effect);
36
37export function __setPrimaryHttpRunnerForTests(runner?: PrimaryHttpEffectRunner): void {
38 primaryHttpRunner = runner ?? livePrimaryHttpRunner;
39}
apps/web/src/localApi.test.ts+3-3
...@@ -202,8 +202,8 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg...@@ -202,8 +202,8 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg
202 fetchSshSessionState: async () => {202 fetchSshSessionState: async () => {
203 throw new Error("fetchSshSessionState not implemented in test");203 throw new Error("fetchSshSessionState not implemented in test");
204 },204 },
205 issueSshWebSocketToken: async () => {205 issueSshWebSocketTicket: async () => {
206 throw new Error("issueSshWebSocketToken not implemented in test");206 throw new Error("issueSshWebSocketTicket not implemented in test");
207 },207 },
208 onSshPasswordPrompt: () => () => undefined,208 onSshPasswordPrompt: () => () => undefined,
209 resolveSshPasswordPrompt: async () => undefined,209 resolveSshPasswordPrompt: async () => undefined,
...@@ -289,7 +289,7 @@ const baseServerConfig: ServerConfig = {...@@ -289,7 +289,7 @@ const baseServerConfig: ServerConfig = {
289 auth: {289 auth: {
290 policy: "loopback-browser",290 policy: "loopback-browser",
291 bootstrapMethods: ["one-time-token"],291 bootstrapMethods: ["one-time-token"],
292 sessionMethods: ["browser-session-cookie", "bearer-session-token"],292 sessionMethods: ["browser-session-cookie", "bearer-access-token"],
293 sessionCookieName: "t3_session",293 sessionCookieName: "t3_session",
294 },294 },
295 cwd: "/tmp/workspace",295 cwd: "/tmp/workspace",
apps/web/src/rpc/serverState.test.ts+1-1
...@@ -76,7 +76,7 @@ const baseServerConfig: ServerConfig = {...@@ -76,7 +76,7 @@ const baseServerConfig: ServerConfig = {
76 auth: {76 auth: {
77 policy: "loopback-browser",77 policy: "loopback-browser",
78 bootstrapMethods: ["one-time-token"],78 bootstrapMethods: ["one-time-token"],
79 sessionMethods: ["browser-session-cookie", "bearer-session-token"],79 sessionMethods: ["browser-session-cookie", "bearer-access-token"],
80 sessionCookieName: "t3_session",80 sessionCookieName: "t3_session",
81 },81 },
82 cwd: "/tmp/workspace",82 cwd: "/tmp/workspace",
apps/web/test/authHttpHandlers.ts+77-15
...@@ -1,11 +1,27 @@...@@ -1,11 +1,27 @@
1import {1import {
2 AuthSessionId,
3 EnvironmentAuthenticatedAuth,
4 EnvironmentAuthenticatedPrincipal,
5 EnvironmentAuthHttpApi,
2 EnvironmentId,6 EnvironmentId,
7 EnvironmentMetadataHttpApi,
8 type AuthEnvironmentScope,
3 type ExecutionEnvironmentDescriptor,9 type ExecutionEnvironmentDescriptor,
4 type ServerAuthDescriptor,10 type ServerAuthDescriptor,
5} from "@t3tools/contracts";11} from "@t3tools/contracts";
6import { HttpResponse, http } from "msw";12import * as DateTime from "effect/DateTime";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import { HttpRouter, HttpServer } from "effect/unstable/http";
16import * as HttpApi from "effect/unstable/httpapi/HttpApi";
17import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
18import { http } from "msw";
719
8const TEST_SESSION_EXPIRES_AT = "2026-05-01T12:00:00.000Z";20const BrowserEnvironmentHttpApi = HttpApi.make("browserEnvironment")
21 .add(EnvironmentMetadataHttpApi)
22 .add(EnvironmentAuthHttpApi);
23
24const TEST_SESSION_EXPIRES_AT = DateTime.makeUnsafe("2026-05-01T12:00:00.000Z");
9const TEST_ENVIRONMENT_DESCRIPTOR: ExecutionEnvironmentDescriptor = {25const TEST_ENVIRONMENT_DESCRIPTOR: ExecutionEnvironmentDescriptor = {
10 environmentId: EnvironmentId.make("environment-local"),26 environmentId: EnvironmentId.make("environment-local"),
11 label: "Local environment",27 label: "Local environment",
...@@ -19,23 +35,69 @@ const TEST_ENVIRONMENT_DESCRIPTOR: ExecutionEnvironmentDescriptor = {...@@ -19,23 +35,69 @@ const TEST_ENVIRONMENT_DESCRIPTOR: ExecutionEnvironmentDescriptor = {
19 },35 },
20};36};
2137
38const unexpectedEndpoint = (endpoint: string) =>
39 Effect.die(new Error(`Unexpected browser environment HTTP endpoint: ${endpoint}`));
40
22export function createAuthenticatedSessionHandlers(getAuthDescriptor: () => ServerAuthDescriptor) {41export function createAuthenticatedSessionHandlers(getAuthDescriptor: () => ServerAuthDescriptor) {
23 return [42 const authenticatedAuthLayer = Layer.succeed(EnvironmentAuthenticatedAuth, (httpEffect) =>
24 http.get("*/.well-known/t3/environment", () => HttpResponse.json(TEST_ENVIRONMENT_DESCRIPTOR)),43 httpEffect.pipe(
25 http.get("*/api/auth/session", () =>44 Effect.provideService(EnvironmentAuthenticatedPrincipal, {
26 HttpResponse.json({45 sessionId: AuthSessionId.make("browser-session"),
27 authenticated: true,46 subject: "browser-client",
28 auth: getAuthDescriptor(),47 method: "browser-session-cookie",
29 sessionMethod: "browser-session-cookie",48 scopes: new Set<AuthEnvironmentScope>(),
30 expiresAt: TEST_SESSION_EXPIRES_AT,49 expiresAt: TEST_SESSION_EXPIRES_AT,
31 }),50 }),
32 ),51 ),
33 http.post("*/api/auth/bootstrap", () =>52 );
34 HttpResponse.json({53 const metadataLayer = HttpApiBuilder.group(BrowserEnvironmentHttpApi, "metadata", (handlers) =>
35 authenticated: true,54 handlers.handle("descriptor", () => Effect.succeed(TEST_ENVIRONMENT_DESCRIPTOR)),
36 sessionMethod: "browser-session-cookie",55 );
37 expiresAt: TEST_SESSION_EXPIRES_AT,56 const authLayer = HttpApiBuilder.group(BrowserEnvironmentHttpApi, "auth", (handlers) =>
38 }),57 handlers
58 .handle("session", () =>
59 Effect.succeed({
60 authenticated: true,
61 auth: getAuthDescriptor(),
62 sessionMethod: "browser-session-cookie",
63 expiresAt: TEST_SESSION_EXPIRES_AT,
64 }),
65 )
66 .handle("browserSession", () =>
67 Effect.succeed({
68 authenticated: true,
69 scopes: [
70 "orchestration:read",
71 "orchestration:operate",
72 "terminal:operate",
73 "review:write",
74 "relay:read",
75 ],
76 sessionMethod: "browser-session-cookie",
77 expiresAt: TEST_SESSION_EXPIRES_AT,
78 }),
79 )
80 .handle("token", () => unexpectedEndpoint("auth.token"))
81 .handle("webSocketTicket", () => unexpectedEndpoint("auth.webSocketTicket"))
82 .handle("pairingCredential", () => unexpectedEndpoint("auth.pairingCredential"))
83 .handle("pairingLinks", () => unexpectedEndpoint("auth.pairingLinks"))
84 .handle("revokePairingLink", () => unexpectedEndpoint("auth.revokePairingLink"))
85 .handle("clients", () => unexpectedEndpoint("auth.clients"))
86 .handle("revokeClient", () => unexpectedEndpoint("auth.revokeClient"))
87 .handle("revokeOtherClients", () => unexpectedEndpoint("auth.revokeOtherClients")),
88 ).pipe(Layer.provide(authenticatedAuthLayer));
89 const { handler } = HttpRouter.toWebHandler(
90 HttpApiBuilder.layer(BrowserEnvironmentHttpApi).pipe(
91 Layer.provide(metadataLayer),
92 Layer.provide(authLayer),
93 Layer.provide(authenticatedAuthLayer),
94 Layer.provide(HttpServer.layerServices),
39 ),95 ),
96 { disableLogger: true },
97 );
98
99 return [
100 http.all("*/.well-known/t3/environment", ({ request }) => handler(request)),
101 http.all("*/api/auth/*", ({ request }) => handler(request)),
40 ] as const;102 ] as const;
41}103}
apps/web/test/environmentHttpTest.ts created+136
...@@ -0,0 +1,136 @@
1import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
2import {
3 AuthSessionId,
4 EnvironmentAuthenticatedAuth,
5 EnvironmentAuthenticatedPrincipal,
6 EnvironmentHttpApi,
7 type AuthBrowserSessionRequest,
8 type AuthBrowserSessionResult,
9 type AuthCreatePairingCredentialInput,
10 type AuthEnvironmentScope,
11 type AuthPairingCredentialResult,
12 type AuthSessionState,
13 type ExecutionEnvironmentDescriptor,
14 type EnvironmentAuthInvalidError,
15} from "@t3tools/contracts";
16import * as DateTime from "effect/DateTime";
17import type * as Context from "effect/Context";
18import * as Effect from "effect/Effect";
19import * as Layer from "effect/Layer";
20import * as ManagedRuntime from "effect/ManagedRuntime";
21import { HttpApiTest } from "effect/unstable/httpapi";
22import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
23
24import { PrimaryEnvironmentHttpClient } from "../src/environments/primary/httpClient";
25import { __setPrimaryHttpRunnerForTests } from "../src/lib/runtime";
26
27type BrowserSessionHandler = (
28 payload: AuthBrowserSessionRequest,
29) => Effect.Effect<AuthBrowserSessionResult, EnvironmentAuthInvalidError>;
30
31interface EnvironmentHttpTestScenario {
32 readonly descriptor?: () => Effect.Effect<ExecutionEnvironmentDescriptor>;
33 readonly session?: () => Effect.Effect<AuthSessionState>;
34 readonly browserSession?: BrowserSessionHandler;
35 readonly pairingCredential?: (
36 payload: AuthCreatePairingCredentialInput,
37 ) => Effect.Effect<AuthPairingCredentialResult>;
38}
39
40export interface EnvironmentHttpTestCalls {
41 descriptor: number;
42 session: number;
43 browserSession: Array<AuthBrowserSessionRequest>;
44 pairingCredential: Array<AuthCreatePairingCredentialInput>;
45}
46
47const unexpectedEndpoint = (endpoint: string) =>
48 Effect.die(new Error(`Unexpected environment HTTP endpoint: ${endpoint}`));
49
50const authenticatedAuth: Context.Service.Shape<typeof EnvironmentAuthenticatedAuth> = (
51 httpEffect,
52) =>
53 httpEffect.pipe(
54 Effect.provideService(EnvironmentAuthenticatedPrincipal, {
55 sessionId: AuthSessionId.make("test-session"),
56 subject: "test-client",
57 method: "browser-session-cookie",
58 scopes: new Set<AuthEnvironmentScope>(),
59 expiresAt: DateTime.makeUnsafe("2026-05-01T12:00:00.000Z"),
60 }),
61 );
62
63export async function installEnvironmentHttpTest(scenario: EnvironmentHttpTestScenario) {
64 const calls: EnvironmentHttpTestCalls = {
65 descriptor: 0,
66 session: 0,
67 browserSession: [],
68 pairingCredential: [],
69 };
70
71 const client = await Effect.runPromise(
72 HttpApiTest.groups(EnvironmentHttpApi, ["metadata", "auth"]).pipe(
73 Effect.provide([
74 NodeHttpServer.layerHttpServices,
75 HttpApiBuilder.group(EnvironmentHttpApi, "metadata", (handlers) =>
76 handlers.handle(
77 "descriptor",
78 Effect.fn("test.environment.metadata.descriptor")(function* () {
79 calls.descriptor += 1;
80 return yield* scenario.descriptor?.() ?? unexpectedEndpoint("metadata.descriptor");
81 }),
82 ),
83 ),
84 HttpApiBuilder.group(EnvironmentHttpApi, "auth", (handlers) =>
85 handlers
86 .handle(
87 "session",
88 Effect.fn("test.environment.auth.session")(function* () {
89 calls.session += 1;
90 return yield* scenario.session?.() ?? unexpectedEndpoint("auth.session");
91 }),
92 )
93 .handle(
94 "browserSession",
95 Effect.fn("test.environment.auth.browserSession")(function* ({ payload }) {
96 calls.browserSession.push(payload);
97 return yield* (
98 scenario.browserSession?.(payload) ?? unexpectedEndpoint("auth.browserSession")
99 );
100 }),
101 )
102 .handle("token", () => unexpectedEndpoint("auth.token"))
103 .handle("webSocketTicket", () => unexpectedEndpoint("auth.webSocketTicket"))
104 .handle(
105 "pairingCredential",
106 Effect.fn("test.environment.auth.pairingCredential")(function* ({ payload }) {
107 calls.pairingCredential.push(payload);
108 return yield* (
109 scenario.pairingCredential?.(payload) ??
110 unexpectedEndpoint("auth.pairingCredential")
111 );
112 }),
113 )
114 .handle("pairingLinks", () => unexpectedEndpoint("auth.pairingLinks"))
115 .handle("revokePairingLink", () => unexpectedEndpoint("auth.revokePairingLink"))
116 .handle("clients", () => unexpectedEndpoint("auth.clients"))
117 .handle("revokeClient", () => unexpectedEndpoint("auth.revokeClient"))
118 .handle("revokeOtherClients", () => unexpectedEndpoint("auth.revokeOtherClients")),
119 ),
120 ]),
121 Effect.provideService(EnvironmentAuthenticatedAuth, authenticatedAuth),
122 Effect.scoped,
123 ),
124 );
125
126 const runtime = ManagedRuntime.make(Layer.succeed(PrimaryEnvironmentHttpClient, client));
127 __setPrimaryHttpRunnerForTests((effect) => runtime.runPromise(effect));
128
129 return {
130 calls,
131 async dispose() {
132 __setPrimaryHttpRunnerForTests();
133 await runtime.dispose();
134 },
135 };
136}
bun.lock+5-2
...@@ -20,6 +20,7 @@...@@ -20,6 +20,7 @@
20 "version": "0.0.24",20 "version": "0.0.24",
21 "dependencies": {21 "dependencies": {
22 "@effect/platform-node": "catalog:",22 "@effect/platform-node": "catalog:",
23 "@t3tools/client-runtime": "workspace:*",
23 "@t3tools/contracts": "workspace:*",24 "@t3tools/contracts": "workspace:*",
24 "@t3tools/shared": "workspace:*",25 "@t3tools/shared": "workspace:*",
25 "@t3tools/ssh": "workspace:*",26 "@t3tools/ssh": "workspace:*",
...@@ -179,6 +180,7 @@...@@ -179,6 +180,7 @@
179 "zustand": "^5.0.11",180 "zustand": "^5.0.11",
180 },181 },
181 "devDependencies": {182 "devDependencies": {
183 "@effect/platform-node": "catalog:",
182 "@rolldown/plugin-babel": "^0.2.0",184 "@rolldown/plugin-babel": "^0.2.0",
183 "@tailwindcss/vite": "^4.0.0",185 "@tailwindcss/vite": "^4.0.0",
184 "@tanstack/router-plugin": "^1.161.0",186 "@tanstack/router-plugin": "^1.161.0",
...@@ -187,7 +189,7 @@...@@ -187,7 +189,7 @@
187 "@types/react-dom": "~19.2.3",189 "@types/react-dom": "~19.2.3",
188 "@vercel/config": "^0.3.0",190 "@vercel/config": "^0.3.0",
189 "@vitejs/plugin-react": "^6.0.0",191 "@vitejs/plugin-react": "^6.0.0",
190 "@vitest/browser-playwright": "^4.0.18",192 "@vitest/browser-playwright": "catalog:",
191 "babel-plugin-react-compiler": "1.0.0",193 "babel-plugin-react-compiler": "1.0.0",
192 "msw": "2.12.11",194 "msw": "2.12.11",
193 "playwright": "^1.58.2",195 "playwright": "^1.58.2",
...@@ -345,10 +347,11 @@...@@ -345,10 +347,11 @@
345 "@types/bun": "^1.3.11",347 "@types/bun": "^1.3.11",
346 "@types/node": "^24.10.13",348 "@types/node": "^24.10.13",
347 "@typescript/native-preview": "7.0.0-dev.20260527.2",349 "@typescript/native-preview": "7.0.0-dev.20260527.2",
350 "@vitest/browser-playwright": "4.1.0",
348 "effect": "4.0.0-beta.73",351 "effect": "4.0.0-beta.73",
349 "tsdown": "^0.20.3",352 "tsdown": "^0.20.3",
350 "typescript": "~6.0.3",353 "typescript": "~6.0.3",
351 "vitest": "^4.0.0",354 "vitest": "4.1.0",
352 },355 },
353 "packages": {356 "packages": {
354 "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],357 "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
docs/environment-auth.md created+113
...@@ -0,0 +1,113 @@
1# Environment Authentication Profile
2
3The environment server and the relay use separate credentials, issuers, and trust
4boundaries. They intentionally use a similar OAuth-shaped model so that permission
5checks and token exchange behavior can be audited against established concepts.
6
7## Authorization Model
8
9Environment authorization is capability-based. A session carries zero or more
10OAuth-style scope strings:
11
12| Scope | Permission |
13| ----------------------- | ------------------------------------------------------------------------ |
14| `orchestration:read` | Read snapshots, status, events, configuration, and filesystem/VCS state. |
15| `orchestration:operate` | Dispatch user operations and mutate environment-side workspace state. |
16| `terminal:operate` | Create, attach, input, resize, clear, restart, and terminate terminals. |
17| `review:write` | Read review diff previews used to compose review feedback. |
18| `access:read` | Inspect pairing links and client sessions. |
19| `access:write` | Create or revoke pairing links and client sessions. |
20| `relay:read` | Inspect managed relay connectivity. |
21| `relay:write` | Link, configure, or unlink managed relay connectivity. |
22
23Ordinary pairing links grant the four client-operation scopes and read access to
24managed relay connectivity:
25`orchestration:read orchestration:operate terminal:operate review:write relay:read`.
26The desktop bootstrap credential and command-line administrative bootstrap
27credentials additionally grant `access:read access:write relay:write`.
28
29## Authentication Flows
30
31### Browser Session
32
33`POST /api/auth/browser-session` consumes a one-time bootstrap credential and creates a
34browser session cookie. The cookie is an HTTP transport adapter for the same
35scoped session model; the response never exposes the session secret to browser
36JavaScript.
37
38### Bearer Access Token
39
40Non-browser clients use `POST /oauth/token` with an
41`application/x-www-form-urlencoded` body:
42
43```text
44grant_type=urn:ietf:params:oauth:grant-type:token-exchange
45subject_token=<bootstrap credential>
46subject_token_type=urn:t3:params:oauth:token-type:environment-bootstrap
47requested_token_type=urn:ietf:params:oauth:token-type:access_token
48scope=orchestration:read orchestration:operate terminal:operate review:write relay:read
49```
50
51Clients may additionally submit `client_label`, `client_device_type`, and
52`client_os` extension parameters so the authorized-clients UI can identify the
53device that established the session. These are presentation hints only; the
54environment derives transport metadata such as IP address and user agent from
55the request and does not use these fields for authorization.
56
57The response has the token-exchange shape:
58
59```json
60{
61 "access_token": "<opaque session token>",
62 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
63 "token_type": "Bearer",
64 "expires_in": 3600,
65 "scope": "orchestration:read orchestration:operate terminal:operate review:write relay:read"
66}
67```
68
69Requested scopes must be a subset of the one-time bootstrap credential grant.
70An ordinary paired client therefore cannot exchange its grant for
71`access:read`, `access:write`, or `relay:write`.
72
73### WebSocket Ticket
74
75`POST /api/auth/websocket-ticket` accepts any authenticated session and returns
76a short-lived, single-purpose WebSocket ticket. This keeps bearer tokens and
77browser cookies out of WebSocket URLs while allowing the socket handshake to
78authenticate. The ticket carries its session's scopes; each RPC method then
79enforces `orchestration:read`, `orchestration:operate`, `terminal:operate`,
80`review:write`, or `access:read` as appropriate. Review feedback submission
81currently dispatches an orchestration operation, so clients performing it also
82need `orchestration:operate`. Creating a ticket is not
83authorization to call every RPC method.
84
85## Standards Alignment
86
87- Bearer access tokens are used through the `Authorization: Bearer` scheme from
88 RFC 6750.
89- The token endpoint profiles the request and response vocabulary from OAuth 2.0
90 Token Exchange (RFC 8693), including `subject_token`, `requested_token_type`,
91 `access_token`, `issued_token_type`, and `token_type`.
92- Scope values follow the OAuth 2.0 scope model from RFC 6749: space-delimited,
93 unordered capabilities with subset checking during exchange.
94
95This is intentionally not a general-purpose OAuth authorization server. The
96environment bootstrap token type is private, the bootstrap cookie and WebSocket
97connection-token routes are product-specific adapters, and the API returns its
98typed `HttpApi` errors rather than implementing every OAuth error response
99surface.
100
101## Upgrade Behavior
102
103Migration `031_AuthAuthorizationScopes` is a hard cutover from role-bearing auth
104records to scoped records. It deletes existing pairing links and sessions while
105leaving non-authentication environment state unchanged. Upgraded clients must
106pair again; old `owner` or `client` credentials are never silently mapped to new
107capabilities.
108
109## Relay Boundary
110
111Relay-managed tunnels use their own tokens and keys. The relay can reuse scope
112parsing and token-exchange conventions, but an environment access token is not a
113relay token and cannot be presented to the relay.
package.json+2-1
...@@ -24,7 +24,8 @@...@@ -24,7 +24,8 @@
24 "@typescript/native-preview": "7.0.0-dev.20260527.2",24 "@typescript/native-preview": "7.0.0-dev.20260527.2",
25 "tsdown": "^0.20.3",25 "tsdown": "^0.20.3",
26 "typescript": "~6.0.3",26 "typescript": "~6.0.3",
27 "vitest": "^4.0.0"27 "@vitest/browser-playwright": "4.1.0",
28 "vitest": "4.1.0"
28 }29 }
29 },30 },
30 "type": "module",31 "type": "module",
packages/client-runtime/src/remote.test.ts+167-32
...@@ -2,19 +2,23 @@ import { describe, expect, it } from "@effect/vitest";...@@ -2,19 +2,23 @@ import { describe, expect, it } from "@effect/vitest";
2import * as Duration from "effect/Duration";2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";4import * as Fiber from "effect/Fiber";
5import * as Schema from "effect/Schema";
5import * as TestClock from "effect/testing/TestClock";6import * as TestClock from "effect/testing/TestClock";
6import { HttpClient } from "effect/unstable/http";
77
8import { EnvironmentAuthInvalidError } from "@t3tools/contracts";
8import {9import {
9 bootstrapRemoteBearerSession,10 bootstrapRemoteBearerSession,
10 fetchRemoteEnvironmentDescriptor,11 fetchRemoteEnvironmentDescriptor,
11 fetchRemoteSessionState,12 fetchRemoteSessionState,
12 issueRemoteWebSocketToken,13 issueRemoteWebSocketTicket,
13 remoteHttpClientLayer,14 remoteHttpClientLayer,
15 RemoteEnvironmentAuthInvalidJsonError,
14 RemoteEnvironmentAuthTimeoutError,16 RemoteEnvironmentAuthTimeoutError,
15 resolveRemoteWebSocketConnectionUrl,17 resolveRemoteWebSocketConnectionUrl,
16} from "./remote.ts";18} from "./remote.ts";
1719
20const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError);
21
18type FetchCall = readonly [input: RequestInfo | URL, init: RequestInit];22type FetchCall = readonly [input: RequestInfo | URL, init: RequestInit];
1923
20const recordedFetch = (...responses: ReadonlyArray<Response>) => {24const recordedFetch = (...responses: ReadonlyArray<Response>) => {
...@@ -42,10 +46,7 @@ const hangingFetch = () => {...@@ -42,10 +46,7 @@ const hangingFetch = () => {
42 return { fetchFn, calls };46 return { fetchFn, calls };
43};47};
4448
45const provideRemoteHttp =49const provideRemoteHttp = (fetchFn: typeof fetch) => Effect.provide(remoteHttpClientLayer(fetchFn));
46 (fetchFn: typeof fetch) =>
47 <A, E, R>(effect: Effect.Effect<A, E, R | HttpClient.HttpClient>) =>
48 effect.pipe(Effect.provide(remoteHttpClientLayer(fetchFn)));
4950
50const expectFetchCall = (51const expectFetchCall = (
51 calls: ReadonlyArray<FetchCall>,52 calls: ReadonlyArray<FetchCall>,
...@@ -90,11 +91,12 @@ describe("remote", () => {...@@ -90,11 +91,12 @@ describe("remote", () => {
90 const fetch = recordedFetch(91 const fetch = recordedFetch(
91 Response.json(92 Response.json(
92 {93 {
93 authenticated: true,94 access_token: "bearer-token",
94 role: "client",95 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
95 sessionMethod: "bearer-session-token",96 token_type: "Bearer",
96 expiresAt: "2026-05-01T12:00:00.000Z",97 expires_in: 3600,
97 sessionToken: "bearer-token",98 scope:
99 "orchestration:read orchestration:operate terminal:operate review:write relay:read",
98 },100 },
99 { status: 200 },101 { status: 200 },
100 ),102 ),
...@@ -106,16 +108,80 @@ describe("remote", () => {...@@ -106,16 +108,80 @@ describe("remote", () => {
106 }).pipe(provideRemoteHttp(fetch.fetchFn));108 }).pipe(provideRemoteHttp(fetch.fetchFn));
107109
108 expect(result).toMatchObject({110 expect(result).toMatchObject({
109 sessionMethod: "bearer-session-token",111 token_type: "Bearer",
110 sessionToken: "bearer-token",112 access_token: "bearer-token",
113 scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read",
111 });114 });
112 expectFetchCall(fetch.calls, 1, {115 expectFetchCall(fetch.calls, 1, {
113 url: "https://remote.example.com/api/auth/bootstrap/bearer",116 url: "https://remote.example.com/oauth/token",
114 method: "POST",117 method: "POST",
115 headers: {118 headers: {
116 "content-type": "application/json",119 "content-type": "application/x-www-form-urlencoded",
117 },120 },
118 body: `{"credential":"pairing-token"}`,121 body: "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&subject_token=pairing-token&subject_token_type=urn%3At3%3Aparams%3Aoauth%3Atoken-type%3Aenvironment-bootstrap&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token",
122 });
123 }),
124 );
125
126 it.effect("submits optional client display metadata during bearer token exchange", () =>
127 Effect.gen(function* () {
128 const fetch = recordedFetch(
129 Response.json(
130 {
131 access_token: "bearer-token",
132 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
133 token_type: "Bearer",
134 expires_in: 3600,
135 scope:
136 "orchestration:read orchestration:operate terminal:operate review:write relay:read",
137 },
138 { status: 200 },
139 ),
140 );
141
142 yield* bootstrapRemoteBearerSession({
143 httpBaseUrl: "https://remote.example.com/",
144 credential: "pairing-token",
145 clientMetadata: {
146 label: "T3 Code Mobile",
147 deviceType: "mobile",
148 os: "iOS",
149 },
150 }).pipe(provideRemoteHttp(fetch.fetchFn));
151
152 expectFetchCall(fetch.calls, 1, {
153 url: "https://remote.example.com/oauth/token",
154 method: "POST",
155 body: "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&subject_token=pairing-token&subject_token_type=urn%3At3%3Aparams%3Aoauth%3Atoken-type%3Aenvironment-bootstrap&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&client_label=T3+Code+Mobile&client_device_type=mobile&client_os=iOS",
156 });
157 }),
158 );
159
160 it.effect("allows a client to explicitly narrow a pairing grant", () =>
161 Effect.gen(function* () {
162 const fetch = recordedFetch(
163 Response.json(
164 {
165 access_token: "read-only-token",
166 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
167 token_type: "Bearer",
168 expires_in: 3600,
169 scope: "orchestration:read",
170 },
171 { status: 200 },
172 ),
173 );
174
175 yield* bootstrapRemoteBearerSession({
176 httpBaseUrl: "https://remote.example.com/",
177 credential: "pairing-token",
178 scopes: ["orchestration:read"],
179 }).pipe(provideRemoteHttp(fetch.fetchFn));
180
181 expectFetchCall(fetch.calls, 1, {
182 url: "https://remote.example.com/oauth/token",
183 method: "POST",
184 body: "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&subject_token=pairing-token&subject_token_type=urn%3At3%3Aparams%3Aoauth%3Atoken-type%3Aenvironment-bootstrap&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&scope=orchestration%3Aread",
119 });185 });
120 }),186 }),
121 );187 );
...@@ -144,18 +210,24 @@ describe("remote", () => {...@@ -144,18 +210,24 @@ describe("remote", () => {
144 auth: {210 auth: {
145 policy: "remote-reachable",211 policy: "remote-reachable",
146 bootstrapMethods: ["one-time-token"],212 bootstrapMethods: ["one-time-token"],
147 sessionMethods: ["browser-session-cookie", "bearer-session-token"],213 sessionMethods: ["browser-session-cookie", "bearer-access-token"],
148 sessionCookieName: "t3_session",214 sessionCookieName: "t3_session",
149 },215 },
150 role: "client",216 scopes: [
151 sessionMethod: "bearer-session-token",217 "orchestration:read",
218 "orchestration:operate",
219 "terminal:operate",
220 "review:write",
221 "relay:read",
222 ],
223 sessionMethod: "bearer-access-token",
152 expiresAt: "2026-05-01T12:00:00.000Z",224 expiresAt: "2026-05-01T12:00:00.000Z",
153 },225 },
154 { status: 200 },226 { status: 200 },
155 ),227 ),
156 Response.json(228 Response.json(
157 {229 {
158 token: "ws-token",230 ticket: "ws-ticket",
159 expiresAt: "2026-05-01T12:05:00.000Z",231 expiresAt: "2026-05-01T12:05:00.000Z",
160 },232 },
161 { status: 200 },233 { status: 200 },
...@@ -176,15 +248,21 @@ describe("remote", () => {...@@ -176,15 +248,21 @@ describe("remote", () => {
176 }).pipe(provideRemoteHttp(fetch.fetchFn));248 }).pipe(provideRemoteHttp(fetch.fetchFn));
177 expect(session).toMatchObject({249 expect(session).toMatchObject({
178 authenticated: true,250 authenticated: true,
179 role: "client",251 scopes: [
252 "orchestration:read",
253 "orchestration:operate",
254 "terminal:operate",
255 "review:write",
256 "relay:read",
257 ],
180 });258 });
181259
182 const token = yield* issueRemoteWebSocketToken({260 const token = yield* issueRemoteWebSocketTicket({
183 httpBaseUrl: "https://remote.example.com/",261 httpBaseUrl: "https://remote.example.com/",
184 bearerToken: "bearer-token",262 bearerToken: "bearer-token",
185 }).pipe(provideRemoteHttp(fetch.fetchFn));263 }).pipe(provideRemoteHttp(fetch.fetchFn));
186 expect(token).toMatchObject({264 expect(token).toMatchObject({
187 token: "ws-token",265 ticket: "ws-ticket",
188 });266 });
189267
190 expectFetchCall(fetch.calls, 1, {268 expectFetchCall(fetch.calls, 1, {
...@@ -199,7 +277,7 @@ describe("remote", () => {...@@ -199,7 +277,7 @@ describe("remote", () => {
199 },277 },
200 });278 });
201 expectFetchCall(fetch.calls, 3, {279 expectFetchCall(fetch.calls, 3, {
202 url: "https://remote.example.com/api/auth/ws-token",280 url: "https://remote.example.com/api/auth/websocket-ticket",
203 method: "POST",281 method: "POST",
204 headers: {282 headers: {
205 authorization: "Bearer bearer-token",283 authorization: "Bearer bearer-token",
...@@ -227,25 +305,82 @@ describe("remote", () => {...@@ -227,25 +305,82 @@ describe("remote", () => {
227 }).pipe(Effect.provide(TestClock.layer())),305 }).pipe(Effect.provide(TestClock.layer())),
228 );306 );
229307
230 it.effect("mints a websocket url that targets the rpc route with a short-lived ws token", () =>308 it.effect("revives declared typed errors from remote auth failures", () =>
231 Effect.gen(function* () {309 Effect.gen(function* () {
232 const fetch = recordedFetch(310 const fetch = recordedFetch(
233 Response.json(311 Response.json(
234 {312 {
235 token: "ws-token",313 _tag: "EnvironmentAuthInvalidError",
236 expiresAt: "2026-05-01T12:05:00.000Z",314 code: "auth_invalid",
315 reason: "missing_credential",
316 traceId: "trace-auth-test",
317 },
318 { status: 401 },
319 ),
320 );
321
322 const error = yield* issueRemoteWebSocketTicket({
323 httpBaseUrl: "https://remote.example.com/",
324 bearerToken: "expired-token",
325 }).pipe(provideRemoteHttp(fetch.fetchFn), Effect.flip);
326
327 expect(isEnvironmentAuthInvalidError(error)).toBe(true);
328 if (isEnvironmentAuthInvalidError(error)) {
329 expect(error.reason).toBe("missing_credential");
330 expect(error.traceId).toBe("trace-auth-test");
331 }
332 }),
333 );
334
335 it.effect("classifies malformed successful remote auth responses as invalid responses", () =>
336 Effect.gen(function* () {
337 const fetch = recordedFetch(
338 Response.json(
339 {
340 access_token: "",
341 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
342 token_type: "Bearer",
343 expires_in: 3600,
344 scope:
345 "orchestration:read orchestration:operate terminal:operate review:write relay:read",
237 },346 },
238 { status: 200 },347 { status: 200 },
239 ),348 ),
240 );349 );
241350
242 const url = yield* resolveRemoteWebSocketConnectionUrl({351 const error = yield* bootstrapRemoteBearerSession({
243 wsBaseUrl: "wss://remote.example.com/",
244 httpBaseUrl: "https://remote.example.com/",352 httpBaseUrl: "https://remote.example.com/",
245 bearerToken: "bearer-token",353 credential: "pairing-token",
246 }).pipe(provideRemoteHttp(fetch.fetchFn));354 }).pipe(provideRemoteHttp(fetch.fetchFn), Effect.flip);
247355
248 expect(url).toBe("wss://remote.example.com/ws?wsToken=ws-token");356 expect(error).toBeInstanceOf(RemoteEnvironmentAuthInvalidJsonError);
357 expect(error.message).toBe(
358 "Remote auth endpoint returned an invalid response from https://remote.example.com/oauth/token.",
359 );
249 }),360 }),
250 );361 );
362
363 it.effect(
364 "mints a websocket url that targets the rpc route with a short-lived websocket ticket",
365 () =>
366 Effect.gen(function* () {
367 const fetch = recordedFetch(
368 Response.json(
369 {
370 ticket: "ws-ticket",
371 expiresAt: "2026-05-01T12:05:00.000Z",
372 },
373 { status: 200 },
374 ),
375 );
376
377 const url = yield* resolveRemoteWebSocketConnectionUrl({
378 wsBaseUrl: "wss://remote.example.com/",
379 httpBaseUrl: "https://remote.example.com/",
380 bearerToken: "bearer-token",
381 }).pipe(provideRemoteHttp(fetch.fetchFn));
382
383 expect(url).toBe("wss://remote.example.com/ws?wsTicket=ws-ticket");
384 }),
385 );
251});386});
packages/client-runtime/src/remote.ts+145-142
...@@ -1,30 +1,31 @@...@@ -1,30 +1,31 @@
1import {
2 AuthAccessTokenType,
3 type AuthClientPresentationMetadata,
4 AuthEnvironmentBootstrapTokenType,
5 AuthTokenExchangeGrantType,
6 type AuthEnvironmentScope,
7 EnvironmentHttpApi,
8 EnvironmentHttpCommonError,
9} from "@t3tools/contracts";
10import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
1import type {11import type {
2 AuthBearerBootstrapResult,12 EnvironmentAuthInvalidError,
3 AuthSessionState,13 EnvironmentInternalError,
4 AuthWebSocketTokenResult,14 EnvironmentOperationForbiddenError,
5 ExecutionEnvironmentDescriptor,15 EnvironmentRequestInvalidError,
16 EnvironmentScopeRequiredError,
6} from "@t3tools/contracts";17} from "@t3tools/contracts";
7import { decodeJsonResult } from "@t3tools/shared/schemaJson";
8import * as Data from "effect/Data";18import * as Data from "effect/Data";
9import * as Duration from "effect/Duration";19import * as Duration from "effect/Duration";
10import * as Effect from "effect/Effect";20import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";21import * as Layer from "effect/Layer";
12import * as Option from "effect/Option";22import * as Option from "effect/Option";
13import * as Result from "effect/Result";
14import * as Schema from "effect/Schema";23import * as Schema from "effect/Schema";
15import { identity } from "effect/Function";24import { FetchHttpClient, HttpClient, HttpClientError } from "effect/unstable/http";
16import {25import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
17 FetchHttpClient,
18 HttpClient,
19 HttpClientRequest,
20 HttpClientResponse,
21} from "effect/unstable/http";
2226
23const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000;27const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000;
24const RemoteAuthErrorBody = Schema.Struct({28const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError);
25 error: Schema.optional(Schema.String),
26});
27const decodeRemoteAuthErrorBody = decodeJsonResult(RemoteAuthErrorBody);
2829
29const remoteEndpointUrl = (httpBaseUrl: string, pathname: string): string => {30const remoteEndpointUrl = (httpBaseUrl: string, pathname: string): string => {
30 const url = new URL(httpBaseUrl);31 const url = new URL(httpBaseUrl);
...@@ -34,6 +35,14 @@ const remoteEndpointUrl = (httpBaseUrl: string, pathname: string): string => {...@@ -34,6 +35,14 @@ const remoteEndpointUrl = (httpBaseUrl: string, pathname: string): string => {
34 return url.toString();35 return url.toString();
35};36};
3637
38const remoteApiBaseUrl = (httpBaseUrl: string): string => {
39 const url = new URL(httpBaseUrl);
40 url.pathname = "/";
41 url.search = "";
42 url.hash = "";
43 return url.toString();
44};
45
37export class RemoteEnvironmentAuthFetchError extends Data.TaggedError(46export class RemoteEnvironmentAuthFetchError extends Data.TaggedError(
38 "RemoteEnvironmentAuthFetchError",47 "RemoteEnvironmentAuthFetchError",
39)<{48)<{
...@@ -41,13 +50,6 @@ export class RemoteEnvironmentAuthFetchError extends Data.TaggedError(...@@ -41,13 +50,6 @@ export class RemoteEnvironmentAuthFetchError extends Data.TaggedError(
41 readonly cause: unknown;50 readonly cause: unknown;
42}> {}51}> {}
4352
44export class RemoteEnvironmentAuthResponseReadError extends Data.TaggedError(
45 "RemoteEnvironmentAuthResponseReadError",
46)<{
47 readonly message: string;
48 readonly cause: unknown;
49}> {}
50
51export class RemoteEnvironmentAuthInvalidJsonError extends Data.TaggedError(53export class RemoteEnvironmentAuthInvalidJsonError extends Data.TaggedError(
52 "RemoteEnvironmentAuthInvalidJsonError",54 "RemoteEnvironmentAuthInvalidJsonError",
53)<{55)<{
...@@ -55,14 +57,19 @@ export class RemoteEnvironmentAuthInvalidJsonError extends Data.TaggedError(...@@ -55,14 +57,19 @@ export class RemoteEnvironmentAuthInvalidJsonError extends Data.TaggedError(
55 readonly cause: unknown;57 readonly cause: unknown;
56}> {}58}> {}
5759
58export class RemoteEnvironmentAuthHttpError extends Data.TaggedError(60export class RemoteEnvironmentAuthUndeclaredStatusError extends Data.TaggedError(
59 "RemoteEnvironmentAuthHttpError",61 "RemoteEnvironmentAuthUndeclaredStatusError",
60)<{62)<{
61 readonly message: string;63 readonly message: string;
62 readonly status: number;64 readonly status: number;
65 readonly requestUrl: string;
63}> {66}> {
64 constructor(message: string, status: number) {67 constructor(requestUrl: string, status: number) {
65 super({ message, status });68 super({
69 message: `Remote auth endpoint ${requestUrl} returned undeclared status ${status}.`,
70 requestUrl,
71 status,
72 });
66 }73 }
67}74}
6875
...@@ -83,87 +90,67 @@ export class RemoteEnvironmentAuthTimeoutError extends Data.TaggedError(...@@ -83,87 +90,67 @@ export class RemoteEnvironmentAuthTimeoutError extends Data.TaggedError(
83}90}
8491
85export type RemoteEnvironmentAuthError =92export type RemoteEnvironmentAuthError =
93 | EnvironmentRequestInvalidError
94 | EnvironmentAuthInvalidError
95 | EnvironmentScopeRequiredError
96 | EnvironmentOperationForbiddenError
97 | EnvironmentInternalError
86 | RemoteEnvironmentAuthFetchError98 | RemoteEnvironmentAuthFetchError
87 | RemoteEnvironmentAuthResponseReadError
88 | RemoteEnvironmentAuthInvalidJsonError99 | RemoteEnvironmentAuthInvalidJsonError
89 | RemoteEnvironmentAuthHttpError100 | RemoteEnvironmentAuthUndeclaredStatusError
90 | RemoteEnvironmentAuthTimeoutError;101 | RemoteEnvironmentAuthTimeoutError;
91102
92export const isRemoteEnvironmentAuthHttpError = (
93 error: unknown,
94): error is RemoteEnvironmentAuthHttpError => error instanceof RemoteEnvironmentAuthHttpError;
95
96const readRemoteAuthErrorMessage = (
97 response: HttpClientResponse.HttpClientResponse,
98 fallbackMessage: string,
99): Effect.Effect<string, RemoteEnvironmentAuthResponseReadError> =>
100 response.text.pipe(
101 Effect.mapError(
102 (cause) =>
103 new RemoteEnvironmentAuthResponseReadError({
104 message: "Remote auth endpoint returned an unreadable error response.",
105 cause,
106 }),
107 ),
108 Effect.map((text) => {
109 if (!text) {
110 return fallbackMessage;
111 }
112
113 const decoded = decodeRemoteAuthErrorBody(text);
114 if (Result.isSuccess(decoded) && decoded.success.error) {
115 return decoded.success.error;
116 }
117
118 return text;
119 }),
120 );
121
122const readRemoteJson = <T>(
123 response: HttpClientResponse.HttpClientResponse,
124 requestUrl: string,
125): Effect.Effect<T, RemoteEnvironmentAuthInvalidJsonError> =>
126 response.json.pipe(
127 Effect.mapError(
128 (cause) =>
129 new RemoteEnvironmentAuthInvalidJsonError({
130 message: `Remote auth endpoint returned invalid JSON from ${requestUrl}.`,
131 cause,
132 }),
133 ),
134 Effect.map((value) => value as T),
135 );
136
137export const remoteHttpClientLayer = (103export const remoteHttpClientLayer = (
138 fetchFn: typeof globalThis.fetch,104 fetchFn: typeof globalThis.fetch,
139): Layer.Layer<HttpClient.HttpClient> =>105): Layer.Layer<HttpClient.HttpClient> =>
140 FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn)));106 FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn)));
141107
142const fetchRemoteJson = Effect.fn("clientRuntime.remote.fetchRemoteJson")(function* <T>(input: {108const failRemoteRequest = (
143 readonly httpBaseUrl: string;109 requestUrl: string,
144 readonly pathname: string;110 cause: unknown,
145 readonly method?: "GET" | "POST";111): Effect.Effect<never, RemoteEnvironmentAuthError> => {
146 readonly bearerToken?: string;112 if (cause instanceof RemoteEnvironmentAuthTimeoutError) {
147 readonly body?: unknown;113 return Effect.fail(cause);
148 readonly timeoutMs?: number;114 }
149}) {115 if (isEnvironmentHttpCommonError(cause)) {
150 const requestUrl = remoteEndpointUrl(input.httpBaseUrl, input.pathname);116 return Effect.fail(cause);
151 const method = input.method ?? "GET";117 }
152 const timeoutMs = input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS;118 if (Schema.isSchemaError(cause)) {
153 const request = HttpClientRequest.make(method)(requestUrl).pipe(119 return Effect.fail(
154 input.bearerToken ? HttpClientRequest.bearerToken(input.bearerToken) : identity,120 new RemoteEnvironmentAuthInvalidJsonError({
155 input.body !== undefined ? HttpClientRequest.bodyJsonUnsafe(input.body) : identity,121 message: `Remote auth endpoint returned an invalid response from ${requestUrl}.`,
122 cause,
123 }),
124 );
125 }
126 if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) {
127 const response = cause.response;
128 if (response.status < 200 || response.status >= 300) {
129 return Effect.fail(
130 new RemoteEnvironmentAuthUndeclaredStatusError(requestUrl, response.status),
131 );
132 }
133 return Effect.fail(
134 new RemoteEnvironmentAuthInvalidJsonError({
135 message: `Remote auth endpoint returned an invalid response from ${requestUrl}.`,
136 cause,
137 }),
138 );
139 }
140 return Effect.fail(
141 new RemoteEnvironmentAuthFetchError({
142 message: `Failed to fetch remote auth endpoint ${requestUrl} (${String(cause)}).`,
143 cause,
144 }),
156 );145 );
146};
157147
158 const client = yield* HttpClient.HttpClient;148const executeRemoteRequest = <A, E, R>(
159 const response = yield* client.execute(request).pipe(149 requestUrl: string,
160 Effect.mapError(150 timeoutMs: number,
161 (cause) =>151 request: Effect.Effect<A, E, R>,
162 new RemoteEnvironmentAuthFetchError({152): Effect.Effect<A, RemoteEnvironmentAuthError, R> =>
163 message: `Failed to fetch remote auth endpoint ${requestUrl} (${String(cause)}).`,153 request.pipe(
164 cause,
165 }),
166 ),
167 Effect.timeoutOption(Duration.millis(timeoutMs)),154 Effect.timeoutOption(Duration.millis(timeoutMs)),
168 Effect.flatMap(155 Effect.flatMap(
169 Option.match({156 Option.match({
...@@ -171,38 +158,46 @@ const fetchRemoteJson = Effect.fn("clientRuntime.remote.fetchRemoteJson")(functi...@@ -171,38 +158,46 @@ const fetchRemoteJson = Effect.fn("clientRuntime.remote.fetchRemoteJson")(functi
171 onSome: Effect.succeed,158 onSome: Effect.succeed,
172 }),159 }),
173 ),160 ),
161 Effect.catch((cause) => failRemoteRequest(requestUrl, cause)),
174 );162 );
175163
176 if (response.status < 200 || response.status >= 300) {164export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) =>
177 return yield* readRemoteAuthErrorMessage(165 Effect.gen(function* () {
178 response,166 const httpClient = yield* HttpClient.HttpClient;
179 `Remote auth request failed (${response.status}).`,167 return yield* HttpApiClient.makeWith(EnvironmentHttpApi, {
180 ).pipe(168 httpClient,
181 Effect.flatMap((message) =>169 baseUrl: remoteApiBaseUrl(httpBaseUrl),
182 Effect.fail(new RemoteEnvironmentAuthHttpError(message, response.status)),170 });
183 ),171 });
184 );
185 }
186
187 return yield* readRemoteJson<T>(response, requestUrl);
188});
189172
190export const bootstrapRemoteBearerSession = Effect.fn(173export const bootstrapRemoteBearerSession = Effect.fn(
191 "clientRuntime.remote.bootstrapRemoteBearerSession",174 "clientRuntime.remote.bootstrapRemoteBearerSession",
192)(function* (input: {175)(function* (input: {
193 readonly httpBaseUrl: string;176 readonly httpBaseUrl: string;
194 readonly credential: string;177 readonly credential: string;
178 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
179 readonly clientMetadata?: AuthClientPresentationMetadata;
195 readonly timeoutMs?: number;180 readonly timeoutMs?: number;
196}) {181}) {
197 return yield* fetchRemoteJson<AuthBearerBootstrapResult>({182 const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl);
198 httpBaseUrl: input.httpBaseUrl,183 return yield* executeRemoteRequest(
199 pathname: "/api/auth/bootstrap/bearer",184 remoteEndpointUrl(input.httpBaseUrl, "/oauth/token"),
200 method: "POST",185 input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS,
201 ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}),186 client.auth.token({
202 body: {187 payload: {
203 credential: input.credential,188 grant_type: AuthTokenExchangeGrantType,
204 },189 subject_token: input.credential,
205 });190 subject_token_type: AuthEnvironmentBootstrapTokenType,
191 requested_token_type: AuthAccessTokenType,
192 ...(input.scopes ? { scope: encodeOAuthScope(input.scopes) } : {}),
193 ...(input.clientMetadata?.label ? { client_label: input.clientMetadata.label } : {}),
194 ...(input.clientMetadata?.deviceType
195 ? { client_device_type: input.clientMetadata.deviceType }
196 : {}),
197 ...(input.clientMetadata?.os ? { client_os: input.clientMetadata.os } : {}),
198 },
199 }),
200 );
206});201});
207202
208export const fetchRemoteSessionState = Effect.fn("clientRuntime.remote.fetchRemoteSessionState")(203export const fetchRemoteSessionState = Effect.fn("clientRuntime.remote.fetchRemoteSessionState")(
...@@ -211,39 +206,47 @@ export const fetchRemoteSessionState = Effect.fn("clientRuntime.remote.fetchRemo...@@ -211,39 +206,47 @@ export const fetchRemoteSessionState = Effect.fn("clientRuntime.remote.fetchRemo
211 readonly bearerToken: string;206 readonly bearerToken: string;
212 readonly timeoutMs?: number;207 readonly timeoutMs?: number;
213 }) {208 }) {
214 return yield* fetchRemoteJson<AuthSessionState>({209 const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl);
215 httpBaseUrl: input.httpBaseUrl,210 return yield* executeRemoteRequest(
216 pathname: "/api/auth/session",211 remoteEndpointUrl(input.httpBaseUrl, "/api/auth/session"),
217 bearerToken: input.bearerToken,212 input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS,
218 ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}),213 client.auth.session({
219 });214 headers: {
215 authorization: `Bearer ${input.bearerToken}`,
216 },
217 }),
218 );
220 },219 },
221);220);
222221
223export const fetchRemoteEnvironmentDescriptor = Effect.fn(222export const fetchRemoteEnvironmentDescriptor = Effect.fn(
224 "clientRuntime.remote.fetchRemoteEnvironmentDescriptor",223 "clientRuntime.remote.fetchRemoteEnvironmentDescriptor",
225)(function* (input: { readonly httpBaseUrl: string; readonly timeoutMs?: number }) {224)(function* (input: { readonly httpBaseUrl: string; readonly timeoutMs?: number }) {
226 return yield* fetchRemoteJson<ExecutionEnvironmentDescriptor>({225 const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl);
227 httpBaseUrl: input.httpBaseUrl,226 return yield* executeRemoteRequest(
228 pathname: "/.well-known/t3/environment",227 remoteEndpointUrl(input.httpBaseUrl, "/.well-known/t3/environment"),
229 ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}),228 input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS,
230 });229 client.metadata.descriptor(),
230 );
231});231});
232232
233export const issueRemoteWebSocketToken = Effect.fn(233export const issueRemoteWebSocketTicket = Effect.fn(
234 "clientRuntime.remote.issueRemoteWebSocketToken",234 "clientRuntime.remote.issueRemoteWebSocketTicket",
235)(function* (input: {235)(function* (input: {
236 readonly httpBaseUrl: string;236 readonly httpBaseUrl: string;
237 readonly bearerToken: string;237 readonly bearerToken: string;
238 readonly timeoutMs?: number;238 readonly timeoutMs?: number;
239}) {239}) {
240 return yield* fetchRemoteJson<AuthWebSocketTokenResult>({240 const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl);
241 httpBaseUrl: input.httpBaseUrl,241 return yield* executeRemoteRequest(
242 pathname: "/api/auth/ws-token",242 remoteEndpointUrl(input.httpBaseUrl, "/api/auth/websocket-ticket"),
243 method: "POST",243 input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS,
244 bearerToken: input.bearerToken,244 client.auth.webSocketTicket({
245 ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}),245 headers: {
246 });246 authorization: `Bearer ${input.bearerToken}`,
247 },
248 }),
249 );
247});250});
248251
249export const resolveRemoteWebSocketConnectionUrl = Effect.fn(252export const resolveRemoteWebSocketConnectionUrl = Effect.fn(
...@@ -254,7 +257,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn(...@@ -254,7 +257,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn(
254 readonly bearerToken: string;257 readonly bearerToken: string;
255 readonly timeoutMs?: number;258 readonly timeoutMs?: number;
256}) {259}) {
257 const issued = yield* issueRemoteWebSocketToken({260 const issued = yield* issueRemoteWebSocketTicket({
258 httpBaseUrl: input.httpBaseUrl,261 httpBaseUrl: input.httpBaseUrl,
259 bearerToken: input.bearerToken,262 bearerToken: input.bearerToken,
260 ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}),263 ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}),
...@@ -264,6 +267,6 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn(...@@ -264,6 +267,6 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn(
264 if (url.pathname === "" || url.pathname === "/") {267 if (url.pathname === "" || url.pathname === "/") {
265 url.pathname = "/ws";268 url.pathname = "/ws";
266 }269 }
267 url.searchParams.set("wsToken", issued.token);270 url.searchParams.set("wsTicket", issued.ticket);
268 return url.toString();271 return url.toString();
269});272});
packages/client-runtime/src/wsTransport.test.ts+2-2
...@@ -170,13 +170,13 @@ describe("WsTransport", () => {...@@ -170,13 +170,13 @@ describe("WsTransport", () => {
170 });170 });
171171
172 it("supports async websocket url providers", async () => {172 it("supports async websocket url providers", async () => {
173 const transport = createTransport(async () => "wss://remote.example.com/?wsToken=dynamic");173 const transport = createTransport(async () => "wss://remote.example.com/?wsTicket=dynamic");
174174
175 await waitFor(() => {175 await waitFor(() => {
176 expect(sockets).toHaveLength(1);176 expect(sockets).toHaveLength(1);
177 });177 });
178178
179 expect(getSocket().url).toBe("wss://remote.example.com/ws?wsToken=dynamic");179 expect(getSocket().url).toBe("wss://remote.example.com/ws?wsTicket=dynamic");
180 await transport.dispose();180 await transport.dispose();
181 });181 });
182182
packages/contracts/src/auth.ts+106-31
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
2import * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema";
23
3import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas.ts";4import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas.ts";
45
...@@ -60,17 +61,56 @@ export type ServerAuthBootstrapMethod = typeof ServerAuthBootstrapMethod.Type;...@@ -60,17 +61,56 @@ export type ServerAuthBootstrapMethod = typeof ServerAuthBootstrapMethod.Type;
60 * Current methods:61 * Current methods:
61 * - `browser-session-cookie`: cookie-backed browser session, used by the web62 * - `browser-session-cookie`: cookie-backed browser session, used by the web
62 * app after bootstrap/pairing63 * app after bootstrap/pairing
63 * - `bearer-session-token`: token-based session suitable for non-cookie or64 * - `bearer-access-token`: scoped token suitable for non-cookie or
64 * non-browser clients65 * non-browser clients
65 */66 */
66export const ServerAuthSessionMethod = Schema.Literals([67export const ServerAuthSessionMethod = Schema.Literals([
67 "browser-session-cookie",68 "browser-session-cookie",
68 "bearer-session-token",69 "bearer-access-token",
69]);70]);
70export type ServerAuthSessionMethod = typeof ServerAuthSessionMethod.Type;71export type ServerAuthSessionMethod = typeof ServerAuthSessionMethod.Type;
7172
72export const AuthSessionRole = Schema.Literals(["owner", "client"]);73export const AuthOrchestrationReadScope = "orchestration:read" as const;
73export type AuthSessionRole = typeof AuthSessionRole.Type;74export const AuthOrchestrationOperateScope = "orchestration:operate" as const;
75export const AuthTerminalOperateScope = "terminal:operate" as const;
76export const AuthReviewWriteScope = "review:write" as const;
77export const AuthAccessReadScope = "access:read" as const;
78export const AuthAccessWriteScope = "access:write" as const;
79export const AuthRelayReadScope = "relay:read" as const;
80export const AuthRelayWriteScope = "relay:write" as const;
81export const AuthEnvironmentScope = Schema.Literals([
82 AuthOrchestrationReadScope,
83 AuthOrchestrationOperateScope,
84 AuthTerminalOperateScope,
85 AuthReviewWriteScope,
86 AuthAccessReadScope,
87 AuthAccessWriteScope,
88 AuthRelayReadScope,
89 AuthRelayWriteScope,
90]);
91export type AuthEnvironmentScope = typeof AuthEnvironmentScope.Type;
92export const AuthEnvironmentScopes = Schema.Array(AuthEnvironmentScope);
93export type AuthEnvironmentScopes = typeof AuthEnvironmentScopes.Type;
94
95export const AuthStandardClientScopes = [
96 AuthOrchestrationReadScope,
97 AuthOrchestrationOperateScope,
98 AuthTerminalOperateScope,
99 AuthReviewWriteScope,
100 AuthRelayReadScope,
101] as const;
102export const AuthAdministrativeScopes = [
103 ...AuthStandardClientScopes,
104 AuthAccessReadScope,
105 AuthAccessWriteScope,
106 AuthRelayWriteScope,
107] as const;
108
109export const AuthTokenExchangeGrantType =
110 "urn:ietf:params:oauth:grant-type:token-exchange" as const;
111export const AuthAccessTokenType = "urn:ietf:params:oauth:token-type:access_token" as const;
112export const AuthEnvironmentBootstrapTokenType =
113 "urn:t3:params:oauth:token-type:environment-bootstrap" as const;
74114
75/**115/**
76 * Server-advertised auth capabilities for a specific execution environment.116 * Server-advertised auth capabilities for a specific execution environment.
...@@ -100,33 +140,61 @@ export const ServerAuthDescriptor = Schema.Struct({...@@ -100,33 +140,61 @@ export const ServerAuthDescriptor = Schema.Struct({
100});140});
101export type ServerAuthDescriptor = typeof ServerAuthDescriptor.Type;141export type ServerAuthDescriptor = typeof ServerAuthDescriptor.Type;
102142
103export const AuthBootstrapInput = Schema.Struct({143export const AuthBrowserSessionRequest = Schema.Struct({
104 credential: TrimmedNonEmptyString,144 credential: TrimmedNonEmptyString,
105});145});
106export type AuthBootstrapInput = typeof AuthBootstrapInput.Type;146export type AuthBrowserSessionRequest = typeof AuthBrowserSessionRequest.Type;
107147
108export const AuthBootstrapResult = Schema.Struct({148export const AuthBrowserSessionResult = Schema.Struct({
109 authenticated: Schema.Literal(true),149 authenticated: Schema.Literal(true),
110 role: AuthSessionRole,150 scopes: AuthEnvironmentScopes,
111 sessionMethod: ServerAuthSessionMethod,151 sessionMethod: ServerAuthSessionMethod,
112 expiresAt: Schema.DateTimeUtc,152 expiresAt: Schema.DateTimeUtc,
113});153});
114export type AuthBootstrapResult = typeof AuthBootstrapResult.Type;154export type AuthBrowserSessionResult = typeof AuthBrowserSessionResult.Type;
115155
116export const AuthBearerBootstrapResult = Schema.Struct({156export const AuthClientMetadataDeviceType = Schema.Literals([
117 authenticated: Schema.Literal(true),157 "desktop",
118 role: AuthSessionRole,158 "mobile",
119 sessionMethod: Schema.Literal("bearer-session-token"),159 "tablet",
120 expiresAt: Schema.DateTimeUtc,160 "bot",
121 sessionToken: TrimmedNonEmptyString,161 "unknown",
162]);
163export type AuthClientMetadataDeviceType = typeof AuthClientMetadataDeviceType.Type;
164
165export const AuthClientPresentationMetadata = Schema.Struct({
166 label: Schema.optionalKey(TrimmedNonEmptyString),
167 deviceType: Schema.optionalKey(AuthClientMetadataDeviceType),
168 os: Schema.optionalKey(TrimmedNonEmptyString),
169});
170export type AuthClientPresentationMetadata = typeof AuthClientPresentationMetadata.Type;
171
172export const AuthTokenExchangeRequest = Schema.Struct({
173 grant_type: Schema.Literal(AuthTokenExchangeGrantType),
174 subject_token: TrimmedNonEmptyString,
175 subject_token_type: Schema.Literal(AuthEnvironmentBootstrapTokenType),
176 requested_token_type: Schema.Literal(AuthAccessTokenType),
177 scope: Schema.optionalKey(TrimmedNonEmptyString),
178 client_label: Schema.optionalKey(TrimmedNonEmptyString),
179 client_device_type: Schema.optionalKey(AuthClientMetadataDeviceType),
180 client_os: Schema.optionalKey(TrimmedNonEmptyString),
181}).pipe(HttpApiSchema.asFormUrlEncoded());
182export type AuthTokenExchangeRequest = typeof AuthTokenExchangeRequest.Type;
183
184export const AuthAccessTokenResult = Schema.Struct({
185 access_token: TrimmedNonEmptyString,
186 issued_token_type: Schema.Literal(AuthAccessTokenType),
187 token_type: Schema.Literal("Bearer"),
188 expires_in: Schema.Number,
189 scope: TrimmedNonEmptyString,
122});190});
123export type AuthBearerBootstrapResult = typeof AuthBearerBootstrapResult.Type;191export type AuthAccessTokenResult = typeof AuthAccessTokenResult.Type;
124192
125export const AuthWebSocketTokenResult = Schema.Struct({193export const AuthWebSocketTicketResult = Schema.Struct({
126 token: TrimmedNonEmptyString,194 ticket: TrimmedNonEmptyString,
127 expiresAt: Schema.DateTimeUtc,195 expiresAt: Schema.DateTimeUtc,
128});196});
129export type AuthWebSocketTokenResult = typeof AuthWebSocketTokenResult.Type;197export type AuthWebSocketTicketResult = typeof AuthWebSocketTicketResult.Type;
130198
131export const AuthPairingCredentialResult = Schema.Struct({199export const AuthPairingCredentialResult = Schema.Struct({
132 id: TrimmedNonEmptyString,200 id: TrimmedNonEmptyString,
...@@ -139,7 +207,7 @@ export type AuthPairingCredentialResult = typeof AuthPairingCredentialResult.Typ...@@ -139,7 +207,7 @@ export type AuthPairingCredentialResult = typeof AuthPairingCredentialResult.Typ
139export const AuthPairingLink = Schema.Struct({207export const AuthPairingLink = Schema.Struct({
140 id: TrimmedNonEmptyString,208 id: TrimmedNonEmptyString,
141 credential: TrimmedNonEmptyString,209 credential: TrimmedNonEmptyString,
142 role: AuthSessionRole,210 scopes: AuthEnvironmentScopes,
143 subject: TrimmedNonEmptyString,211 subject: TrimmedNonEmptyString,
144 label: Schema.optionalKey(TrimmedNonEmptyString),212 label: Schema.optionalKey(TrimmedNonEmptyString),
145 createdAt: Schema.DateTimeUtc,213 createdAt: Schema.DateTimeUtc,
...@@ -147,15 +215,6 @@ export const AuthPairingLink = Schema.Struct({...@@ -147,15 +215,6 @@ export const AuthPairingLink = Schema.Struct({
147});215});
148export type AuthPairingLink = typeof AuthPairingLink.Type;216export type AuthPairingLink = typeof AuthPairingLink.Type;
149217
150export const AuthClientMetadataDeviceType = Schema.Literals([
151 "desktop",
152 "mobile",
153 "tablet",
154 "bot",
155 "unknown",
156]);
157export type AuthClientMetadataDeviceType = typeof AuthClientMetadataDeviceType.Type;
158
159export const AuthClientMetadata = Schema.Struct({218export const AuthClientMetadata = Schema.Struct({
160 label: Schema.optionalKey(TrimmedNonEmptyString),219 label: Schema.optionalKey(TrimmedNonEmptyString),
161 ipAddress: Schema.optionalKey(TrimmedNonEmptyString),220 ipAddress: Schema.optionalKey(TrimmedNonEmptyString),
...@@ -169,7 +228,7 @@ export type AuthClientMetadata = typeof AuthClientMetadata.Type;...@@ -169,7 +228,7 @@ export type AuthClientMetadata = typeof AuthClientMetadata.Type;
169export const AuthClientSession = Schema.Struct({228export const AuthClientSession = Schema.Struct({
170 sessionId: AuthSessionId,229 sessionId: AuthSessionId,
171 subject: TrimmedNonEmptyString,230 subject: TrimmedNonEmptyString,
172 role: AuthSessionRole,231 scopes: AuthEnvironmentScopes,
173 method: ServerAuthSessionMethod,232 method: ServerAuthSessionMethod,
174 client: AuthClientMetadata,233 client: AuthClientMetadata,
175 issuedAt: Schema.DateTimeUtc,234 issuedAt: Schema.DateTimeUtc,
...@@ -214,6 +273,21 @@ export const AuthAccessStreamPairingLinkRemovedEvent = Schema.Struct({...@@ -214,6 +273,21 @@ export const AuthAccessStreamPairingLinkRemovedEvent = Schema.Struct({
214export type AuthAccessStreamPairingLinkRemovedEvent =273export type AuthAccessStreamPairingLinkRemovedEvent =
215 typeof AuthAccessStreamPairingLinkRemovedEvent.Type;274 typeof AuthAccessStreamPairingLinkRemovedEvent.Type;
216275
276export class AuthAccessStreamError extends Schema.TaggedErrorClass<AuthAccessStreamError>()(
277 "AuthAccessStreamError",
278 {
279 message: Schema.String,
280 },
281) {}
282
283export class EnvironmentAuthorizationError extends Schema.TaggedErrorClass<EnvironmentAuthorizationError>()(
284 "EnvironmentAuthorizationError",
285 {
286 message: Schema.String,
287 requiredScope: AuthEnvironmentScope,
288 },
289) {}
290
217export const AuthAccessStreamClientUpsertedEvent = Schema.Struct({291export const AuthAccessStreamClientUpsertedEvent = Schema.Struct({
218 version: Schema.Literal(1),292 version: Schema.Literal(1),
219 revision: Schema.Number,293 revision: Schema.Number,
...@@ -253,13 +327,14 @@ export type AuthRevokeClientSessionInput = typeof AuthRevokeClientSessionInput.T...@@ -253,13 +327,14 @@ export type AuthRevokeClientSessionInput = typeof AuthRevokeClientSessionInput.T
253327
254export const AuthCreatePairingCredentialInput = Schema.Struct({328export const AuthCreatePairingCredentialInput = Schema.Struct({
255 label: Schema.optionalKey(TrimmedNonEmptyString),329 label: Schema.optionalKey(TrimmedNonEmptyString),
330 scopes: Schema.optionalKey(AuthEnvironmentScopes),
256});331});
257export type AuthCreatePairingCredentialInput = typeof AuthCreatePairingCredentialInput.Type;332export type AuthCreatePairingCredentialInput = typeof AuthCreatePairingCredentialInput.Type;
258333
259export const AuthSessionState = Schema.Struct({334export const AuthSessionState = Schema.Struct({
260 authenticated: Schema.Boolean,335 authenticated: Schema.Boolean,
261 auth: ServerAuthDescriptor,336 auth: ServerAuthDescriptor,
262 role: Schema.optionalKey(AuthSessionRole),337 scopes: Schema.optionalKey(AuthEnvironmentScopes),
263 sessionMethod: Schema.optionalKey(ServerAuthSessionMethod),338 sessionMethod: Schema.optionalKey(ServerAuthSessionMethod),
264 expiresAt: Schema.optionalKey(Schema.DateTimeUtc),339 expiresAt: Schema.optionalKey(Schema.DateTimeUtc),
265});340});
packages/contracts/src/environmentHttp.ts created+325
...@@ -0,0 +1,325 @@
1import * as Context from "effect/Context";
2import type * as DateTime from "effect/DateTime";
3import * as Schema from "effect/Schema";
4import * as HttpApi from "effect/unstable/httpapi/HttpApi";
5import * as HttpApiEndpoint from "effect/unstable/httpapi/HttpApiEndpoint";
6import * as HttpApiGroup from "effect/unstable/httpapi/HttpApiGroup";
7import * as HttpApiMiddleware from "effect/unstable/httpapi/HttpApiMiddleware";
8import * as HttpServerRespondable from "effect/unstable/http/HttpServerRespondable";
9import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
10
11import {
12 AuthAccessTokenResult,
13 AuthBrowserSessionRequest,
14 AuthBrowserSessionResult,
15 AuthClientSession,
16 AuthCreatePairingCredentialInput,
17 AuthPairingCredentialResult,
18 AuthPairingLink,
19 AuthRevokeClientSessionInput,
20 AuthRevokePairingLinkInput,
21 AuthEnvironmentScope,
22 AuthTokenExchangeRequest,
23 AuthSessionState,
24 AuthWebSocketTicketResult,
25 ServerAuthSessionMethod,
26} from "./auth.ts";
27import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas.ts";
28import { ExecutionEnvironmentDescriptor } from "./environment.ts";
29import {
30 ClientOrchestrationCommand,
31 DispatchResult,
32 OrchestrationReadModel,
33} from "./orchestration.ts";
34
35const OptionalBearerHeaders = Schema.Struct({
36 authorization: Schema.optionalKey(Schema.String),
37});
38
39export const EnvironmentRequestInvalidReason = Schema.Literals([
40 "invalid_scope",
41 "scope_not_granted",
42 "invalid_command",
43]);
44export type EnvironmentRequestInvalidReason = typeof EnvironmentRequestInvalidReason.Type;
45
46export const EnvironmentAuthInvalidReason = Schema.Literals([
47 "missing_credential",
48 "invalid_credential",
49]);
50export type EnvironmentAuthInvalidReason = typeof EnvironmentAuthInvalidReason.Type;
51
52export const EnvironmentOperationForbiddenReason = Schema.Literals([
53 "current_session_revoke_not_allowed",
54]);
55export type EnvironmentOperationForbiddenReason = typeof EnvironmentOperationForbiddenReason.Type;
56
57export const EnvironmentInternalErrorReason = Schema.Literals([
58 "bootstrap_validation_failed",
59 "browser_session_issuance_failed",
60 "browser_session_cookie_failed",
61 "access_token_issuance_failed",
62 "websocket_ticket_issuance_failed",
63 "pairing_credential_issuance_failed",
64 "pairing_links_load_failed",
65 "pairing_link_revoke_failed",
66 "client_sessions_load_failed",
67 "client_session_revoke_failed",
68 "orchestration_snapshot_failed",
69 "orchestration_dispatch_failed",
70 "internal_error",
71]);
72export type EnvironmentInternalErrorReason = typeof EnvironmentInternalErrorReason.Type;
73
74export class EnvironmentRequestInvalidError extends Schema.TaggedErrorClass<EnvironmentRequestInvalidError>()(
75 "EnvironmentRequestInvalidError",
76 {
77 code: Schema.Literal("invalid_request"),
78 reason: EnvironmentRequestInvalidReason,
79 traceId: TrimmedNonEmptyString,
80 },
81 { httpApiStatus: 400 },
82) {
83 [HttpServerRespondable.symbol]() {
84 return HttpServerResponse.schemaJson(EnvironmentRequestInvalidError)(this, { status: 400 });
85 }
86}
87
88export class EnvironmentAuthInvalidError extends Schema.TaggedErrorClass<EnvironmentAuthInvalidError>()(
89 "EnvironmentAuthInvalidError",
90 {
91 code: Schema.Literal("auth_invalid"),
92 reason: EnvironmentAuthInvalidReason,
93 traceId: TrimmedNonEmptyString,
94 },
95 { httpApiStatus: 401 },
96) {
97 [HttpServerRespondable.symbol]() {
98 return HttpServerResponse.schemaJson(EnvironmentAuthInvalidError)(this, { status: 401 });
99 }
100}
101
102export class EnvironmentScopeRequiredError extends Schema.TaggedErrorClass<EnvironmentScopeRequiredError>()(
103 "EnvironmentScopeRequiredError",
104 {
105 code: Schema.Literal("insufficient_scope"),
106 requiredScope: AuthEnvironmentScope,
107 traceId: TrimmedNonEmptyString,
108 },
109 { httpApiStatus: 403 },
110) {
111 [HttpServerRespondable.symbol]() {
112 return HttpServerResponse.schemaJson(EnvironmentScopeRequiredError)(this, { status: 403 });
113 }
114}
115
116export class EnvironmentOperationForbiddenError extends Schema.TaggedErrorClass<EnvironmentOperationForbiddenError>()(
117 "EnvironmentOperationForbiddenError",
118 {
119 code: Schema.Literal("operation_forbidden"),
120 reason: EnvironmentOperationForbiddenReason,
121 traceId: TrimmedNonEmptyString,
122 },
123 { httpApiStatus: 403 },
124) {
125 [HttpServerRespondable.symbol]() {
126 return HttpServerResponse.schemaJson(EnvironmentOperationForbiddenError)(this, { status: 403 });
127 }
128}
129
130export class EnvironmentInternalError extends Schema.TaggedErrorClass<EnvironmentInternalError>()(
131 "EnvironmentInternalError",
132 {
133 code: Schema.Literal("internal_error"),
134 reason: EnvironmentInternalErrorReason,
135 traceId: TrimmedNonEmptyString,
136 },
137 { httpApiStatus: 500 },
138) {
139 [HttpServerRespondable.symbol]() {
140 return HttpServerResponse.schemaJson(EnvironmentInternalError)(this, { status: 500 });
141 }
142}
143
144export const EnvironmentHttpCommonError = Schema.Union([
145 EnvironmentRequestInvalidError,
146 EnvironmentAuthInvalidError,
147 EnvironmentScopeRequiredError,
148 EnvironmentOperationForbiddenError,
149 EnvironmentInternalError,
150]);
151export type EnvironmentHttpCommonError = typeof EnvironmentHttpCommonError.Type;
152
153const EnvironmentAuthenticationErrors = [
154 EnvironmentAuthInvalidError,
155 EnvironmentInternalError,
156] as const;
157const EnvironmentSessionCreationErrors = [
158 EnvironmentAuthInvalidError,
159 EnvironmentInternalError,
160] as const;
161const EnvironmentTokenExchangeErrors = [
162 EnvironmentRequestInvalidError,
163 EnvironmentAuthInvalidError,
164 EnvironmentInternalError,
165] as const;
166const EnvironmentScopedOperationErrors = [
167 EnvironmentScopeRequiredError,
168 EnvironmentInternalError,
169] as const;
170const EnvironmentPairingCredentialErrors = [
171 EnvironmentRequestInvalidError,
172 ...EnvironmentScopedOperationErrors,
173] as const;
174const EnvironmentSessionRevokeErrors = [
175 EnvironmentScopeRequiredError,
176 EnvironmentOperationForbiddenError,
177 EnvironmentInternalError,
178] as const;
179const EnvironmentOrchestrationSnapshotErrors = [
180 EnvironmentScopeRequiredError,
181 EnvironmentInternalError,
182] as const;
183const EnvironmentOrchestrationDispatchErrors = [
184 EnvironmentRequestInvalidError,
185 EnvironmentScopeRequiredError,
186 EnvironmentInternalError,
187] as const;
188
189export interface EnvironmentSessionPrincipalShape {
190 readonly sessionId: AuthSessionId;
191 readonly subject: string;
192 readonly method: ServerAuthSessionMethod;
193 readonly scopes: ReadonlySet<AuthEnvironmentScope>;
194 readonly expiresAt?: DateTime.DateTime;
195}
196
197export class EnvironmentAuthenticatedPrincipal extends Context.Service<
198 EnvironmentAuthenticatedPrincipal,
199 EnvironmentSessionPrincipalShape
200>()("@t3tools/contracts/environmentHttp/EnvironmentAuthenticatedPrincipal") {}
201
202export class EnvironmentAuthenticatedAuth extends HttpApiMiddleware.Service<
203 EnvironmentAuthenticatedAuth,
204 { provides: EnvironmentAuthenticatedPrincipal }
205>()("EnvironmentAuthenticatedAuth", {
206 error: EnvironmentAuthenticationErrors,
207}) {}
208
209export const AuthPairingLinkRevokeResult = Schema.Struct({
210 revoked: Schema.Boolean,
211});
212export type AuthPairingLinkRevokeResult = typeof AuthPairingLinkRevokeResult.Type;
213
214export const AuthClientSessionRevokeResult = Schema.Struct({
215 revoked: Schema.Boolean,
216});
217export type AuthClientSessionRevokeResult = typeof AuthClientSessionRevokeResult.Type;
218
219export const AuthOtherClientSessionsRevokeResult = Schema.Struct({
220 revokedCount: Schema.Number,
221});
222export type AuthOtherClientSessionsRevokeResult = typeof AuthOtherClientSessionsRevokeResult.Type;
223
224export class EnvironmentMetadataHttpApi extends HttpApiGroup.make("metadata").add(
225 HttpApiEndpoint.get("descriptor", "/.well-known/t3/environment", {
226 success: ExecutionEnvironmentDescriptor,
227 }),
228) {}
229
230export class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth")
231 .add(
232 HttpApiEndpoint.get("session", "/api/auth/session", {
233 headers: OptionalBearerHeaders,
234 success: AuthSessionState,
235 error: [EnvironmentInternalError],
236 }),
237 )
238 .add(
239 HttpApiEndpoint.post("browserSession", "/api/auth/browser-session", {
240 payload: AuthBrowserSessionRequest,
241 success: AuthBrowserSessionResult,
242 error: EnvironmentSessionCreationErrors,
243 }),
244 )
245 .add(
246 HttpApiEndpoint.post("token", "/oauth/token", {
247 payload: AuthTokenExchangeRequest,
248 success: AuthAccessTokenResult,
249 error: EnvironmentTokenExchangeErrors,
250 }),
251 )
252 .add(
253 HttpApiEndpoint.post("webSocketTicket", "/api/auth/websocket-ticket", {
254 headers: OptionalBearerHeaders,
255 success: AuthWebSocketTicketResult,
256 error: [EnvironmentInternalError],
257 }).middleware(EnvironmentAuthenticatedAuth),
258 )
259 .add(
260 HttpApiEndpoint.post("pairingCredential", "/api/auth/pairing-token", {
261 headers: OptionalBearerHeaders,
262 payload: AuthCreatePairingCredentialInput,
263 success: AuthPairingCredentialResult,
264 error: EnvironmentPairingCredentialErrors,
265 }).middleware(EnvironmentAuthenticatedAuth),
266 )
267 .add(
268 HttpApiEndpoint.get("pairingLinks", "/api/auth/pairing-links", {
269 headers: OptionalBearerHeaders,
270 success: Schema.Array(AuthPairingLink),
271 error: EnvironmentScopedOperationErrors,
272 }).middleware(EnvironmentAuthenticatedAuth),
273 )
274 .add(
275 HttpApiEndpoint.post("revokePairingLink", "/api/auth/pairing-links/revoke", {
276 headers: OptionalBearerHeaders,
277 payload: AuthRevokePairingLinkInput,
278 success: AuthPairingLinkRevokeResult,
279 error: EnvironmentScopedOperationErrors,
280 }).middleware(EnvironmentAuthenticatedAuth),
281 )
282 .add(
283 HttpApiEndpoint.get("clients", "/api/auth/clients", {
284 headers: OptionalBearerHeaders,
285 success: Schema.Array(AuthClientSession),
286 error: EnvironmentScopedOperationErrors,
287 }).middleware(EnvironmentAuthenticatedAuth),
288 )
289 .add(
290 HttpApiEndpoint.post("revokeClient", "/api/auth/clients/revoke", {
291 headers: OptionalBearerHeaders,
292 payload: AuthRevokeClientSessionInput,
293 success: AuthClientSessionRevokeResult,
294 error: EnvironmentSessionRevokeErrors,
295 }).middleware(EnvironmentAuthenticatedAuth),
296 )
297 .add(
298 HttpApiEndpoint.post("revokeOtherClients", "/api/auth/clients/revoke-others", {
299 headers: OptionalBearerHeaders,
300 success: AuthOtherClientSessionsRevokeResult,
301 error: EnvironmentScopedOperationErrors,
302 }).middleware(EnvironmentAuthenticatedAuth),
303 ) {}
304
305export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestration")
306 .add(
307 HttpApiEndpoint.get("snapshot", "/api/orchestration/snapshot", {
308 headers: OptionalBearerHeaders,
309 success: OrchestrationReadModel,
310 error: EnvironmentOrchestrationSnapshotErrors,
311 }).middleware(EnvironmentAuthenticatedAuth),
312 )
313 .add(
314 HttpApiEndpoint.post("dispatch", "/api/orchestration/dispatch", {
315 headers: OptionalBearerHeaders,
316 payload: ClientOrchestrationCommand,
317 success: DispatchResult,
318 error: EnvironmentOrchestrationDispatchErrors,
319 }).middleware(EnvironmentAuthenticatedAuth),
320 ) {}
321
322export class EnvironmentHttpApi extends HttpApi.make("environment")
323 .add(EnvironmentMetadataHttpApi)
324 .add(EnvironmentAuthHttpApi)
325 .add(EnvironmentOrchestrationHttpApi) {}
packages/contracts/src/index.ts+1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1export * from "./baseSchemas.ts";1export * from "./baseSchemas.ts";
2export * from "./auth.ts";2export * from "./auth.ts";
3export * from "./environment.ts";3export * from "./environment.ts";
4export * from "./environmentHttp.ts";
4export * from "./desktopBootstrap.ts";5export * from "./desktopBootstrap.ts";
5export * from "./remoteAccess.ts";6export * from "./remoteAccess.ts";
6export * from "./ipc.ts";7export * from "./ipc.ts";
packages/contracts/src/ipc.ts+4-4
...@@ -66,7 +66,7 @@ import type {...@@ -66,7 +66,7 @@ import type {
66 OrchestrationThreadStreamItem,66 OrchestrationThreadStreamItem,
67} from "./orchestration.ts";67} from "./orchestration.ts";
68import { EnvironmentId } from "./baseSchemas.ts";68import { EnvironmentId } from "./baseSchemas.ts";
69import { AuthBearerBootstrapResult, AuthSessionState, AuthWebSocketTokenResult } from "./auth.ts";69import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts";
70import { AdvertisedEndpoint } from "./remoteAccess.ts";70import { AdvertisedEndpoint } from "./remoteAccess.ts";
71import { EditorId } from "./editor.ts";71import { EditorId } from "./editor.ts";
72import { ExecutionEnvironmentDescriptor } from "./environment.ts";72import { ExecutionEnvironmentDescriptor } from "./environment.ts";
...@@ -394,12 +394,12 @@ export interface DesktopBridge {...@@ -394,12 +394,12 @@ export interface DesktopBridge {
394 bootstrapSshBearerSession: (394 bootstrapSshBearerSession: (
395 httpBaseUrl: string,395 httpBaseUrl: string,
396 credential: string,396 credential: string,
397 ) => Promise<AuthBearerBootstrapResult>;397 ) => Promise<AuthAccessTokenResult>;
398 fetchSshSessionState: (httpBaseUrl: string, bearerToken: string) => Promise<AuthSessionState>;398 fetchSshSessionState: (httpBaseUrl: string, bearerToken: string) => Promise<AuthSessionState>;
399 issueSshWebSocketToken: (399 issueSshWebSocketTicket: (
400 httpBaseUrl: string,400 httpBaseUrl: string,
401 bearerToken: string,401 bearerToken: string,
402 ) => Promise<AuthWebSocketTokenResult>;402 ) => Promise<AuthWebSocketTicketResult>;
403 onSshPasswordPrompt: (listener: (request: DesktopSshPasswordPromptRequest) => void) => () => void;403 onSshPasswordPrompt: (listener: (request: DesktopSshPasswordPromptRequest) => void) => () => void;
404 resolveSshPasswordPrompt: (requestId: string, password: string | null) => Promise<void>;404 resolveSshPasswordPrompt: (requestId: string, password: string | null) => Promise<void>;
405 getServerExposureState: () => Promise<DesktopServerExposureState>;405 getServerExposureState: () => Promise<DesktopServerExposureState>;
packages/contracts/src/rpc.ts+56-42
...@@ -3,7 +3,11 @@ import * as Rpc from "effect/unstable/rpc/Rpc";...@@ -3,7 +3,11 @@ import * as Rpc from "effect/unstable/rpc/Rpc";
3import * as RpcGroup from "effect/unstable/rpc/RpcGroup";3import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
44
5import { ExternalLauncherError, LaunchEditorInput } from "./editor.ts";5import { ExternalLauncherError, LaunchEditorInput } from "./editor.ts";
6import { AuthAccessStreamEvent } from "./auth.ts";6import {
7 AuthAccessStreamError,
8 AuthAccessStreamEvent,
9 EnvironmentAuthorizationError,
10} from "./auth.ts";
7import {11import {
8 FilesystemBrowseInput,12 FilesystemBrowseInput,
9 FilesystemBrowseResult,13 FilesystemBrowseResult,
...@@ -179,19 +183,19 @@ export const WS_METHODS = {...@@ -179,19 +183,19 @@ export const WS_METHODS = {
179export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, {183export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, {
180 payload: ServerUpsertKeybindingInput,184 payload: ServerUpsertKeybindingInput,
181 success: ServerUpsertKeybindingResult,185 success: ServerUpsertKeybindingResult,
182 error: KeybindingsConfigError,186 error: Schema.Union([KeybindingsConfigError, EnvironmentAuthorizationError]),
183});187});
184188
185export const WsServerRemoveKeybindingRpc = Rpc.make(WS_METHODS.serverRemoveKeybinding, {189export const WsServerRemoveKeybindingRpc = Rpc.make(WS_METHODS.serverRemoveKeybinding, {
186 payload: ServerRemoveKeybindingInput,190 payload: ServerRemoveKeybindingInput,
187 success: ServerRemoveKeybindingResult,191 success: ServerRemoveKeybindingResult,
188 error: KeybindingsConfigError,192 error: Schema.Union([KeybindingsConfigError, EnvironmentAuthorizationError]),
189});193});
190194
191export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, {195export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, {
192 payload: Schema.Struct({}),196 payload: Schema.Struct({}),
193 success: ServerConfig,197 success: ServerConfig,
194 error: Schema.Union([KeybindingsConfigError, ServerSettingsError]),198 error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]),
195});199});
196200
197export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, {201export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, {
...@@ -205,39 +209,43 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv...@@ -205,39 +209,43 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv
205 instanceId: Schema.optional(ProviderInstanceId),209 instanceId: Schema.optional(ProviderInstanceId),
206 }),210 }),
207 success: ServerProviderUpdatedPayload,211 success: ServerProviderUpdatedPayload,
212 error: EnvironmentAuthorizationError,
208});213});
209214
210export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, {215export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, {
211 payload: ServerProviderUpdateInput,216 payload: ServerProviderUpdateInput,
212 success: ServerProviderUpdatedPayload,217 success: ServerProviderUpdatedPayload,
213 error: ServerProviderUpdateError,218 error: Schema.Union([ServerProviderUpdateError, EnvironmentAuthorizationError]),
214});219});
215220
216export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, {221export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, {
217 payload: Schema.Struct({}),222 payload: Schema.Struct({}),
218 success: ServerSettings,223 success: ServerSettings,
219 error: ServerSettingsError,224 error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]),
220});225});
221226
222export const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSettings, {227export const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSettings, {
223 payload: Schema.Struct({ patch: ServerSettingsPatch }),228 payload: Schema.Struct({ patch: ServerSettingsPatch }),
224 success: ServerSettings,229 success: ServerSettings,
225 error: ServerSettingsError,230 error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]),
226});231});
227232
228export const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscoverSourceControl, {233export const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscoverSourceControl, {
229 payload: Schema.Struct({}),234 payload: Schema.Struct({}),
230 success: SourceControlDiscoveryResult,235 success: SourceControlDiscoveryResult,
236 error: EnvironmentAuthorizationError,
231});237});
232238
233export const WsServerGetTraceDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetTraceDiagnostics, {239export const WsServerGetTraceDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetTraceDiagnostics, {
234 payload: Schema.Struct({}),240 payload: Schema.Struct({}),
235 success: ServerTraceDiagnosticsResult,241 success: ServerTraceDiagnosticsResult,
242 error: EnvironmentAuthorizationError,
236});243});
237244
238export const WsServerGetProcessDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetProcessDiagnostics, {245export const WsServerGetProcessDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetProcessDiagnostics, {
239 payload: Schema.Struct({}),246 payload: Schema.Struct({}),
240 success: ServerProcessDiagnosticsResult,247 success: ServerProcessDiagnosticsResult,
248 error: EnvironmentAuthorizationError,
241});249});
242250
243export const WsServerGetProcessResourceHistoryRpc = Rpc.make(251export const WsServerGetProcessResourceHistoryRpc = Rpc.make(
...@@ -245,12 +253,14 @@ export const WsServerGetProcessResourceHistoryRpc = Rpc.make(...@@ -245,12 +253,14 @@ export const WsServerGetProcessResourceHistoryRpc = Rpc.make(
245 {253 {
246 payload: ServerProcessResourceHistoryInput,254 payload: ServerProcessResourceHistoryInput,
247 success: ServerProcessResourceHistoryResult,255 success: ServerProcessResourceHistoryResult,
256 error: EnvironmentAuthorizationError,
248 },257 },
249);258);
250259
251export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, {260export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, {
252 payload: ServerSignalProcessInput,261 payload: ServerSignalProcessInput,
253 success: ServerSignalProcessResult,262 success: ServerSignalProcessResult,
263 error: EnvironmentAuthorizationError,
254});264});
255265
256export const WsSourceControlLookupRepositoryRpc = Rpc.make(266export const WsSourceControlLookupRepositoryRpc = Rpc.make(
...@@ -258,14 +268,14 @@ export const WsSourceControlLookupRepositoryRpc = Rpc.make(...@@ -258,14 +268,14 @@ export const WsSourceControlLookupRepositoryRpc = Rpc.make(
258 {268 {
259 payload: SourceControlRepositoryLookupInput,269 payload: SourceControlRepositoryLookupInput,
260 success: SourceControlRepositoryInfo,270 success: SourceControlRepositoryInfo,
261 error: SourceControlRepositoryError,271 error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]),
262 },272 },
263);273);
264274
265export const WsSourceControlCloneRepositoryRpc = Rpc.make(WS_METHODS.sourceControlCloneRepository, {275export const WsSourceControlCloneRepositoryRpc = Rpc.make(WS_METHODS.sourceControlCloneRepository, {
266 payload: SourceControlCloneRepositoryInput,276 payload: SourceControlCloneRepositoryInput,
267 success: SourceControlCloneRepositoryResult,277 success: SourceControlCloneRepositoryResult,
268 error: SourceControlRepositoryError,278 error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]),
269});279});
270280
271export const WsSourceControlPublishRepositoryRpc = Rpc.make(281export const WsSourceControlPublishRepositoryRpc = Rpc.make(
...@@ -273,103 +283,103 @@ export const WsSourceControlPublishRepositoryRpc = Rpc.make(...@@ -273,103 +283,103 @@ export const WsSourceControlPublishRepositoryRpc = Rpc.make(
273 {283 {
274 payload: SourceControlPublishRepositoryInput,284 payload: SourceControlPublishRepositoryInput,
275 success: SourceControlPublishRepositoryResult,285 success: SourceControlPublishRepositoryResult,
276 error: SourceControlRepositoryError,286 error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]),
277 },287 },
278);288);
279289
280export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, {290export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, {
281 payload: ProjectSearchEntriesInput,291 payload: ProjectSearchEntriesInput,
282 success: ProjectSearchEntriesResult,292 success: ProjectSearchEntriesResult,
283 error: ProjectSearchEntriesError,293 error: Schema.Union([ProjectSearchEntriesError, EnvironmentAuthorizationError]),
284});294});
285295
286export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, {296export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, {
287 payload: ProjectWriteFileInput,297 payload: ProjectWriteFileInput,
288 success: ProjectWriteFileResult,298 success: ProjectWriteFileResult,
289 error: ProjectWriteFileError,299 error: Schema.Union([ProjectWriteFileError, EnvironmentAuthorizationError]),
290});300});
291301
292export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, {302export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, {
293 payload: LaunchEditorInput,303 payload: LaunchEditorInput,
294 error: ExternalLauncherError,304 error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]),
295});305});
296306
297export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, {307export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, {
298 payload: FilesystemBrowseInput,308 payload: FilesystemBrowseInput,
299 success: FilesystemBrowseResult,309 success: FilesystemBrowseResult,
300 error: FilesystemBrowseError,310 error: Schema.Union([FilesystemBrowseError, EnvironmentAuthorizationError]),
301});311});
302312
303export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, {313export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, {
304 payload: VcsStatusInput,314 payload: VcsStatusInput,
305 success: VcsStatusStreamEvent,315 success: VcsStatusStreamEvent,
306 error: GitManagerServiceError,316 error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]),
307 stream: true,317 stream: true,
308});318});
309319
310export const WsVcsPullRpc = Rpc.make(WS_METHODS.vcsPull, {320export const WsVcsPullRpc = Rpc.make(WS_METHODS.vcsPull, {
311 payload: VcsPullInput,321 payload: VcsPullInput,
312 success: VcsPullResult,322 success: VcsPullResult,
313 error: GitCommandError,323 error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]),
314});324});
315325
316export const WsVcsRefreshStatusRpc = Rpc.make(WS_METHODS.vcsRefreshStatus, {326export const WsVcsRefreshStatusRpc = Rpc.make(WS_METHODS.vcsRefreshStatus, {
317 payload: VcsStatusInput,327 payload: VcsStatusInput,
318 success: VcsStatusResult,328 success: VcsStatusResult,
319 error: GitManagerServiceError,329 error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]),
320});330});
321331
322export const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, {332export const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, {
323 payload: GitRunStackedActionInput,333 payload: GitRunStackedActionInput,
324 success: GitActionProgressEvent,334 success: GitActionProgressEvent,
325 error: GitManagerServiceError,335 error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]),
326 stream: true,336 stream: true,
327});337});
328338
329export const WsGitResolvePullRequestRpc = Rpc.make(WS_METHODS.gitResolvePullRequest, {339export const WsGitResolvePullRequestRpc = Rpc.make(WS_METHODS.gitResolvePullRequest, {
330 payload: GitPullRequestRefInput,340 payload: GitPullRequestRefInput,
331 success: GitResolvePullRequestResult,341 success: GitResolvePullRequestResult,
332 error: GitManagerServiceError,342 error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]),
333});343});
334344
335export const WsGitPreparePullRequestThreadRpc = Rpc.make(WS_METHODS.gitPreparePullRequestThread, {345export const WsGitPreparePullRequestThreadRpc = Rpc.make(WS_METHODS.gitPreparePullRequestThread, {
336 payload: GitPreparePullRequestThreadInput,346 payload: GitPreparePullRequestThreadInput,
337 success: GitPreparePullRequestThreadResult,347 success: GitPreparePullRequestThreadResult,
338 error: GitManagerServiceError,348 error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]),
339});349});
340350
341export const WsVcsListRefsRpc = Rpc.make(WS_METHODS.vcsListRefs, {351export const WsVcsListRefsRpc = Rpc.make(WS_METHODS.vcsListRefs, {
342 payload: VcsListRefsInput,352 payload: VcsListRefsInput,
343 success: VcsListRefsResult,353 success: VcsListRefsResult,
344 error: GitCommandError,354 error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]),
345});355});
346356
347export const WsVcsCreateWorktreeRpc = Rpc.make(WS_METHODS.vcsCreateWorktree, {357export const WsVcsCreateWorktreeRpc = Rpc.make(WS_METHODS.vcsCreateWorktree, {
348 payload: VcsCreateWorktreeInput,358 payload: VcsCreateWorktreeInput,
349 success: VcsCreateWorktreeResult,359 success: VcsCreateWorktreeResult,
350 error: GitCommandError,360 error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]),
351});361});
352362
353export const WsVcsRemoveWorktreeRpc = Rpc.make(WS_METHODS.vcsRemoveWorktree, {363export const WsVcsRemoveWorktreeRpc = Rpc.make(WS_METHODS.vcsRemoveWorktree, {
354 payload: VcsRemoveWorktreeInput,364 payload: VcsRemoveWorktreeInput,
355 error: GitCommandError,365 error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]),
356});366});
357367
358export const WsVcsCreateRefRpc = Rpc.make(WS_METHODS.vcsCreateRef, {368export const WsVcsCreateRefRpc = Rpc.make(WS_METHODS.vcsCreateRef, {
359 payload: VcsCreateRefInput,369 payload: VcsCreateRefInput,
360 success: VcsCreateRefResult,370 success: VcsCreateRefResult,
361 error: GitCommandError,371 error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]),
362});372});
363373
364export const WsVcsSwitchRefRpc = Rpc.make(WS_METHODS.vcsSwitchRef, {374export const WsVcsSwitchRefRpc = Rpc.make(WS_METHODS.vcsSwitchRef, {
365 payload: VcsSwitchRefInput,375 payload: VcsSwitchRefInput,
366 success: VcsSwitchRefResult,376 success: VcsSwitchRefResult,
367 error: GitCommandError,377 error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]),
368});378});
369379
370export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, {380export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, {
371 payload: VcsInitInput,381 payload: VcsInitInput,
372 error: VcsError,382 error: Schema.Union([VcsError, EnvironmentAuthorizationError]),
373});383});
374384
375/**385/**
...@@ -380,46 +390,46 @@ export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, {...@@ -380,46 +390,46 @@ export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, {
380export const WsReviewGetDiffPreviewRpc = Rpc.make(WS_METHODS.reviewGetDiffPreview, {390export const WsReviewGetDiffPreviewRpc = Rpc.make(WS_METHODS.reviewGetDiffPreview, {
381 payload: ReviewDiffPreviewInput,391 payload: ReviewDiffPreviewInput,
382 success: ReviewDiffPreviewResult,392 success: ReviewDiffPreviewResult,
383 error: ReviewDiffPreviewError,393 error: Schema.Union([ReviewDiffPreviewError, EnvironmentAuthorizationError]),
384});394});
385395
386export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, {396export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, {
387 payload: TerminalOpenInput,397 payload: TerminalOpenInput,
388 success: TerminalSessionSnapshot,398 success: TerminalSessionSnapshot,
389 error: TerminalError,399 error: Schema.Union([TerminalError, EnvironmentAuthorizationError]),
390});400});
391401
392export const WsTerminalAttachRpc = Rpc.make(WS_METHODS.terminalAttach, {402export const WsTerminalAttachRpc = Rpc.make(WS_METHODS.terminalAttach, {
393 payload: TerminalAttachInput,403 payload: TerminalAttachInput,
394 success: TerminalAttachStreamEvent,404 success: TerminalAttachStreamEvent,
395 error: TerminalError,405 error: Schema.Union([TerminalError, EnvironmentAuthorizationError]),
396 stream: true,406 stream: true,
397});407});
398408
399export const WsTerminalWriteRpc = Rpc.make(WS_METHODS.terminalWrite, {409export const WsTerminalWriteRpc = Rpc.make(WS_METHODS.terminalWrite, {
400 payload: TerminalWriteInput,410 payload: TerminalWriteInput,
401 error: TerminalError,411 error: Schema.Union([TerminalError, EnvironmentAuthorizationError]),
402});412});
403413
404export const WsTerminalResizeRpc = Rpc.make(WS_METHODS.terminalResize, {414export const WsTerminalResizeRpc = Rpc.make(WS_METHODS.terminalResize, {
405 payload: TerminalResizeInput,415 payload: TerminalResizeInput,
406 error: TerminalError,416 error: Schema.Union([TerminalError, EnvironmentAuthorizationError]),
407});417});
408418
409export const WsTerminalClearRpc = Rpc.make(WS_METHODS.terminalClear, {419export const WsTerminalClearRpc = Rpc.make(WS_METHODS.terminalClear, {
410 payload: TerminalClearInput,420 payload: TerminalClearInput,
411 error: TerminalError,421 error: Schema.Union([TerminalError, EnvironmentAuthorizationError]),
412});422});
413423
414export const WsTerminalRestartRpc = Rpc.make(WS_METHODS.terminalRestart, {424export const WsTerminalRestartRpc = Rpc.make(WS_METHODS.terminalRestart, {
415 payload: TerminalRestartInput,425 payload: TerminalRestartInput,
416 success: TerminalSessionSnapshot,426 success: TerminalSessionSnapshot,
417 error: TerminalError,427 error: Schema.Union([TerminalError, EnvironmentAuthorizationError]),
418});428});
419429
420export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, {430export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, {
421 payload: TerminalCloseInput,431 payload: TerminalCloseInput,
422 error: TerminalError,432 error: Schema.Union([TerminalError, EnvironmentAuthorizationError]),
423});433});
424434
425export const WsOrchestrationDispatchCommandRpc = Rpc.make(435export const WsOrchestrationDispatchCommandRpc = Rpc.make(
...@@ -427,14 +437,14 @@ export const WsOrchestrationDispatchCommandRpc = Rpc.make(...@@ -427,14 +437,14 @@ export const WsOrchestrationDispatchCommandRpc = Rpc.make(
427 {437 {
428 payload: ClientOrchestrationCommand,438 payload: ClientOrchestrationCommand,
429 success: OrchestrationRpcSchemas.dispatchCommand.output,439 success: OrchestrationRpcSchemas.dispatchCommand.output,
430 error: OrchestrationDispatchCommandError,440 error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]),
431 },441 },
432);442);
433443
434export const WsOrchestrationGetTurnDiffRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getTurnDiff, {444export const WsOrchestrationGetTurnDiffRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getTurnDiff, {
435 payload: OrchestrationGetTurnDiffInput,445 payload: OrchestrationGetTurnDiffInput,
436 success: OrchestrationRpcSchemas.getTurnDiff.output,446 success: OrchestrationRpcSchemas.getTurnDiff.output,
437 error: OrchestrationGetTurnDiffError,447 error: Schema.Union([OrchestrationGetTurnDiffError, EnvironmentAuthorizationError]),
438});448});
439449
440export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make(450export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make(
...@@ -442,14 +452,14 @@ export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make(...@@ -442,14 +452,14 @@ export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make(
442 {452 {
443 payload: OrchestrationGetFullThreadDiffInput,453 payload: OrchestrationGetFullThreadDiffInput,
444 success: OrchestrationRpcSchemas.getFullThreadDiff.output,454 success: OrchestrationRpcSchemas.getFullThreadDiff.output,
445 error: OrchestrationGetFullThreadDiffError,455 error: Schema.Union([OrchestrationGetFullThreadDiffError, EnvironmentAuthorizationError]),
446 },456 },
447);457);
448458
449export const WsOrchestrationReplayEventsRpc = Rpc.make(ORCHESTRATION_WS_METHODS.replayEvents, {459export const WsOrchestrationReplayEventsRpc = Rpc.make(ORCHESTRATION_WS_METHODS.replayEvents, {
450 payload: OrchestrationReplayEventsInput,460 payload: OrchestrationReplayEventsInput,
451 success: OrchestrationRpcSchemas.replayEvents.output,461 success: OrchestrationRpcSchemas.replayEvents.output,
452 error: OrchestrationReplayEventsError,462 error: Schema.Union([OrchestrationReplayEventsError, EnvironmentAuthorizationError]),
453});463});
454464
455export const WsOrchestrationGetArchivedShellSnapshotRpc = Rpc.make(465export const WsOrchestrationGetArchivedShellSnapshotRpc = Rpc.make(
...@@ -457,14 +467,14 @@ export const WsOrchestrationGetArchivedShellSnapshotRpc = Rpc.make(...@@ -457,14 +467,14 @@ export const WsOrchestrationGetArchivedShellSnapshotRpc = Rpc.make(
457 {467 {
458 payload: OrchestrationRpcSchemas.getArchivedShellSnapshot.input,468 payload: OrchestrationRpcSchemas.getArchivedShellSnapshot.input,
459 success: OrchestrationRpcSchemas.getArchivedShellSnapshot.output,469 success: OrchestrationRpcSchemas.getArchivedShellSnapshot.output,
460 error: OrchestrationGetSnapshotError,470 error: Schema.Union([OrchestrationGetSnapshotError, EnvironmentAuthorizationError]),
461 },471 },
462);472);
463473
464export const WsOrchestrationSubscribeShellRpc = Rpc.make(ORCHESTRATION_WS_METHODS.subscribeShell, {474export const WsOrchestrationSubscribeShellRpc = Rpc.make(ORCHESTRATION_WS_METHODS.subscribeShell, {
465 payload: OrchestrationRpcSchemas.subscribeShell.input,475 payload: OrchestrationRpcSchemas.subscribeShell.input,
466 success: OrchestrationRpcSchemas.subscribeShell.output,476 success: OrchestrationRpcSchemas.subscribeShell.output,
467 error: OrchestrationGetSnapshotError,477 error: Schema.Union([OrchestrationGetSnapshotError, EnvironmentAuthorizationError]),
468 stream: true,478 stream: true,
469});479});
470480
...@@ -473,7 +483,7 @@ export const WsOrchestrationSubscribeThreadRpc = Rpc.make(...@@ -473,7 +483,7 @@ export const WsOrchestrationSubscribeThreadRpc = Rpc.make(
473 {483 {
474 payload: OrchestrationRpcSchemas.subscribeThread.input,484 payload: OrchestrationRpcSchemas.subscribeThread.input,
475 success: OrchestrationRpcSchemas.subscribeThread.output,485 success: OrchestrationRpcSchemas.subscribeThread.output,
476 error: OrchestrationGetSnapshotError,486 error: Schema.Union([OrchestrationGetSnapshotError, EnvironmentAuthorizationError]),
477 stream: true,487 stream: true,
478 },488 },
479);489);
...@@ -481,31 +491,35 @@ export const WsOrchestrationSubscribeThreadRpc = Rpc.make(...@@ -481,31 +491,35 @@ export const WsOrchestrationSubscribeThreadRpc = Rpc.make(
481export const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTerminalEvents, {491export const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTerminalEvents, {
482 payload: Schema.Struct({}),492 payload: Schema.Struct({}),
483 success: TerminalEvent,493 success: TerminalEvent,
494 error: EnvironmentAuthorizationError,
484 stream: true,495 stream: true,
485});496});
486497
487export const WsSubscribeTerminalMetadataRpc = Rpc.make(WS_METHODS.subscribeTerminalMetadata, {498export const WsSubscribeTerminalMetadataRpc = Rpc.make(WS_METHODS.subscribeTerminalMetadata, {
488 payload: Schema.Struct({}),499 payload: Schema.Struct({}),
489 success: TerminalMetadataStreamEvent,500 success: TerminalMetadataStreamEvent,
501 error: EnvironmentAuthorizationError,
490 stream: true,502 stream: true,
491});503});
492504
493export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerConfig, {505export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerConfig, {
494 payload: Schema.Struct({}),506 payload: Schema.Struct({}),
495 success: ServerConfigStreamEvent,507 success: ServerConfigStreamEvent,
496 error: Schema.Union([KeybindingsConfigError, ServerSettingsError]),508 error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]),
497 stream: true,509 stream: true,
498});510});
499511
500export const WsSubscribeServerLifecycleRpc = Rpc.make(WS_METHODS.subscribeServerLifecycle, {512export const WsSubscribeServerLifecycleRpc = Rpc.make(WS_METHODS.subscribeServerLifecycle, {
501 payload: Schema.Struct({}),513 payload: Schema.Struct({}),
502 success: ServerLifecycleStreamEvent,514 success: ServerLifecycleStreamEvent,
515 error: EnvironmentAuthorizationError,
503 stream: true,516 stream: true,
504});517});
505518
506export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, {519export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, {
507 payload: Schema.Struct({}),520 payload: Schema.Struct({}),
508 success: AuthAccessStreamEvent,521 success: AuthAccessStreamEvent,
522 error: Schema.Union([AuthAccessStreamError, EnvironmentAuthorizationError]),
509 stream: true,523 stream: true,
510});524});
511525
packages/shared/package.json+4
...@@ -79,6 +79,10 @@...@@ -79,6 +79,10 @@
79 "types": "./src/remote.ts",79 "types": "./src/remote.ts",
80 "import": "./src/remote.ts"80 "import": "./src/remote.ts"
81 },81 },
82 "./oauthScope": {
83 "types": "./src/oauthScope.ts",
84 "import": "./src/oauthScope.ts"
85 },
82 "./searchRanking": {86 "./searchRanking": {
83 "types": "./src/searchRanking.ts",87 "types": "./src/searchRanking.ts",
84 "import": "./src/searchRanking.ts"88 "import": "./src/searchRanking.ts"
packages/shared/src/oauthScope.test.ts created+35
...@@ -0,0 +1,35 @@
1import { describe, expect, it } from "vitest";
2
3import { encodeOAuthScope, parseAllowedOAuthScope, parseOAuthScope } from "./oauthScope.ts";
4
5describe("OAuth scopes", () => {
6 it("parses an RFC 6749 space-delimited scope set without duplicating permissions", () => {
7 expect(parseOAuthScope("orchestration:read access:write orchestration:read")).toEqual([
8 "orchestration:read",
9 "access:write",
10 ]);
11 });
12
13 it("rejects whitespace that is not the SP delimiter or introduces empty tokens", () => {
14 expect(parseOAuthScope("orchestration:read\taccess:write")).toBeNull();
15 expect(parseOAuthScope("orchestration:read access:write")).toBeNull();
16 });
17
18 it("encodes and restricts requested scopes to the allowed capability set", () => {
19 expect(encodeOAuthScope(["orchestration:read", "access:write"])).toBe(
20 "orchestration:read access:write",
21 );
22 expect(
23 parseAllowedOAuthScope({
24 value: "orchestration:read access:write",
25 allowedScopes: new Set(["orchestration:read", "access:write"] as const),
26 }),
27 ).toEqual(["orchestration:read", "access:write"]);
28 expect(
29 parseAllowedOAuthScope({
30 value: "orchestration:read relay:write",
31 allowedScopes: new Set(["orchestration:read", "access:write"] as const),
32 }),
33 ).toBeNull();
34 });
35});
packages/shared/src/oauthScope.ts created+40
...@@ -0,0 +1,40 @@
1const OAUTH_SCOPE_TOKEN = /^[\u0021\u0023-\u005b\u005d-\u007e]+$/u;
2
3/**
4 * Decodes an RFC 6749 `scope` value as a set while preserving first-seen order.
5 */
6export function parseOAuthScope(value: string): ReadonlyArray<string> | null {
7 if (value.length === 0) {
8 return null;
9 }
10
11 const scopes = value.split(" ");
12 if (scopes.some((scope) => !OAUTH_SCOPE_TOKEN.test(scope))) {
13 return null;
14 }
15
16 return [...new Set(scopes)];
17}
18
19export function encodeOAuthScope(scopes: ReadonlyArray<string>): string {
20 const encoded = scopes.join(" ");
21 const parsed = parseOAuthScope(encoded);
22 if (parsed === null || parsed.length !== scopes.length) {
23 throw new Error("OAuth scopes must be non-empty, valid, and unique.");
24 }
25 return encoded;
26}
27
28export function parseAllowedOAuthScope<Scope extends string>(input: {
29 readonly value: string;
30 readonly allowedScopes: ReadonlySet<Scope>;
31}): ReadonlyArray<Scope> | null {
32 const scopes = parseOAuthScope(input.value);
33 if (
34 scopes === null ||
35 !scopes.every((scope): scope is Scope => input.allowedScopes.has(scope as Scope))
36 ) {
37 return null;
38 }
39 return scopes;
40}
packages/shared/src/observability.test.ts+16
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Arr from "effect/Array";3import * as Arr from "effect/Array";
4import * as Cause from "effect/Cause";
4import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";6import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
...@@ -12,12 +13,27 @@ import * as Schema from "effect/Schema";...@@ -12,12 +13,27 @@ import * as Schema from "effect/Schema";
12import * as Tracer from "effect/Tracer";13import * as Tracer from "effect/Tracer";
1314
14import {15import {
16 causeErrorTag,
15 compactTraceAttributes,17 compactTraceAttributes,
16 makeLocalFileTracer,18 makeLocalFileTracer,
17 makeTraceSink,19 makeTraceSink,
18 type TraceRecord,20 type TraceRecord,
19} from "./observability.ts";21} from "./observability.ts";
2022
23describe("causeErrorTag", () => {
24 it("reports the tagged failure value instead of the Cause reason wrapper", () => {
25 assert.equal(
26 causeErrorTag(Cause.fail({ _tag: "ServerAuthInvalidCredentialError" })),
27 "ServerAuthInvalidCredentialError",
28 );
29 });
30
31 it("reports structural cause kinds when no typed failure exists", () => {
32 assert.equal(causeErrorTag(Cause.die(new Error("unexpected"))), "Die");
33 assert.equal(causeErrorTag(Cause.interrupt()), "Interrupt");
34 });
35});
36
21const TraceRecordLine = Schema.Struct({37const TraceRecordLine = Schema.Struct({
22 name: Schema.String,38 name: Schema.String,
23 spanId: Schema.String,39 spanId: Schema.String,
packages/shared/src/observability.ts+16
...@@ -73,6 +73,22 @@ export interface OtlpTraceRecord extends BaseTraceRecord {...@@ -73,6 +73,22 @@ export interface OtlpTraceRecord extends BaseTraceRecord {
7373
74export type TraceRecord = EffectTraceRecord | OtlpTraceRecord;74export type TraceRecord = EffectTraceRecord | OtlpTraceRecord;
7575
76function taggedErrorName(error: unknown): string {
77 return typeof error === "object" && error !== null && "_tag" in error
78 ? String(error._tag)
79 : error instanceof Error
80 ? error.name
81 : typeof error;
82}
83
84export function causeErrorTag(cause: Cause.Cause<unknown>): string {
85 const failure = Cause.findErrorOption(cause);
86 if (Option.isSome(failure)) {
87 return taggedErrorName(failure.value);
88 }
89 return cause.reasons[0]?._tag ?? "Empty";
90}
91
76export interface TraceSinkOptions {92export interface TraceSinkOptions {
77 readonly filePath: string;93 readonly filePath: string;
78 readonly maxBytes: number;94 readonly maxBytes: number;
packages/ssh/src/tunnel.test.ts+2-2
...@@ -293,7 +293,7 @@ describe("ssh tunnel scripts", () => {...@@ -293,7 +293,7 @@ describe("ssh tunnel scripts", () => {
293 makeSuccessfulProcess(`{293 makeSuccessfulProcess(`{
294 "id": "88941235-6ed5-4184-a2ff-5339e2075958",294 "id": "88941235-6ed5-4184-a2ff-5339e2075958",
295 "credential": "LCL4R2TPHDKQ",295 "credential": "LCL4R2TPHDKQ",
296 "role": "client",296 "scopes": ["orchestration:read"],
297 "expiresAt": "2026-04-29T01:01:20.994Z"297 "expiresAt": "2026-04-29T01:01:20.994Z"
298}298}
299299
...@@ -321,7 +321,7 @@ describe("ssh tunnel scripts", () => {...@@ -321,7 +321,7 @@ describe("ssh tunnel scripts", () => {
321{321{
322 "id": "88941235-6ed5-4184-a2ff-5339e2075958",322 "id": "88941235-6ed5-4184-a2ff-5339e2075958",
323 "credential": "LCL4R2TPHDKQ",323 "credential": "LCL4R2TPHDKQ",
324 "role": "client",324 "scopes": ["orchestration:read"],
325 "expiresAt": "2026-04-29T01:01:20.994Z"325 "expiresAt": "2026-04-29T01:01:20.994Z"
326}326}
327327
packages/ssh/src/tunnel.ts+17-87
...@@ -160,13 +160,8 @@ const RemotePairingResult = Schema.Struct({...@@ -160,13 +160,8 @@ const RemotePairingResult = Schema.Struct({
160 credential: Schema.String,160 credential: Schema.String,
161});161});
162162
163const RemoteHttpError = Schema.Struct({
164 error: Schema.optional(Schema.String),
165});
166
167const decodeRemoteLaunchResult = Schema.decodeEffect(fromLenientJson(RemoteLaunchResult));163const decodeRemoteLaunchResult = Schema.decodeEffect(fromLenientJson(RemoteLaunchResult));
168const decodeRemotePairingResult = Schema.decodeEffect(fromLenientJson(RemotePairingResult));164const decodeRemotePairingResult = Schema.decodeEffect(fromLenientJson(RemotePairingResult));
169const decodeRemoteHttpError = Schema.decodeEffect(Schema.fromJsonString(RemoteHttpError));
170165
171const decodeRemoteJsonOutput = <A, E>(166const decodeRemoteJsonOutput = <A, E>(
172 stdout: string,167 stdout: string,
...@@ -991,92 +986,27 @@ function isLoopbackHostname(hostname: string): boolean {...@@ -991,92 +986,27 @@ function isLoopbackHostname(hostname: string): boolean {
991 return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost";986 return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost";
992}987}
993988
994function resolveLoopbackSshHttpUrl(989export const resolveLoopbackSshHttpBaseUrl = Effect.fn("ssh/tunnel.resolveLoopbackSshHttpBaseUrl")(
995 rawHttpBaseUrl: unknown,990 function* (rawHttpBaseUrl: unknown): Effect.fn.Return<string, SshHttpBridgeError> {
996 pathname: string,991 return yield* Effect.try({
997): Effect.Effect<URL, SshHttpBridgeError> {992 try: () => {
998 return Effect.try({993 if (typeof rawHttpBaseUrl !== "string" || rawHttpBaseUrl.trim().length === 0) {
999 try: () => {994 throw new Error("Invalid SSH forwarded http base URL.");
1000 if (typeof rawHttpBaseUrl !== "string" || rawHttpBaseUrl.trim().length === 0) {995 }
1001 throw new Error("Invalid SSH forwarded http base URL.");996 const baseUrl = new URL(rawHttpBaseUrl);
1002 }997 if (!isLoopbackHostname(baseUrl.hostname)) {
1003 const baseUrl = new URL(rawHttpBaseUrl);998 throw new Error("SSH desktop bridge only supports loopback forwarded URLs.");
1004 if (!isLoopbackHostname(baseUrl.hostname)) {999 }
1005 throw new Error("SSH desktop bridge only supports loopback forwarded URLs.");1000 return baseUrl.toString();
1006 }1001 },
1007 const url = new URL(baseUrl.toString());1002 catch: (cause) =>
1008 url.pathname = pathname;
1009 url.search = "";
1010 url.hash = "";
1011 return url;
1012 },
1013 catch: (cause) =>
1014 new SshHttpBridgeError({
1015 message: cause instanceof Error ? cause.message : "Invalid SSH forwarded http base URL.",
1016 cause,
1017 }),
1018 });
1019}
1020
1021export const fetchLoopbackSshJson = Effect.fn("ssh/tunnel.fetchLoopbackSshJson")(function* <
1022 T,
1023>(input: {
1024 readonly httpBaseUrl: unknown;
1025 readonly pathname: string;
1026 readonly method?: "GET" | "POST";
1027 readonly bearerToken?: unknown;
1028 readonly body?: unknown;
1029}): Effect.fn.Return<T, SshHttpBridgeError, HttpClient.HttpClient> {
1030 const requestUrl = yield* resolveLoopbackSshHttpUrl(input.httpBaseUrl, input.pathname);
1031 const bearerToken =
1032 typeof input.bearerToken === "string" && input.bearerToken.trim().length > 0
1033 ? input.bearerToken
1034 : null;
1035
1036 const request = (
1037 input.method === "POST"
1038 ? HttpClientRequest.post(requestUrl.toString())
1039 : HttpClientRequest.get(requestUrl.toString())
1040 ).pipe(
1041 input.body === undefined ? (req) => req : HttpClientRequest.bodyJsonUnsafe(input.body),
1042 bearerToken
1043 ? HttpClientRequest.setHeader("authorization", `Bearer ${bearerToken}`)
1044 : (req) => req,
1045 );
1046 const client = yield* HttpClient.HttpClient;
1047 const response = yield* client.execute(request).pipe(
1048 Effect.mapError(
1049 (cause) =>
1050 new SshHttpBridgeError({1003 new SshHttpBridgeError({
1051 message: `Failed to reach SSH forwarded endpoint ${requestUrl.toString()}.`,1004 message: cause instanceof Error ? cause.message : "Invalid SSH forwarded http base URL.",
1052 cause,1005 cause,
1053 }),1006 }),
1054 ),
1055 );
1056 if (response.status < 200 || response.status >= 300) {
1057 const text = yield* response.text.pipe(Effect.catch(() => Effect.succeed("")));
1058 const parsedError = yield* decodeRemoteHttpError(text).pipe(
1059 Effect.catch(() => Effect.succeed(null)),
1060 );
1061 const message =
1062 parsedError?.error && parsedError.error.trim().length > 0
1063 ? parsedError.error
1064 : text || `SSH forwarded request failed (${response.status}).`;
1065 return yield* new SshHttpBridgeError({
1066 status: response.status,
1067 message: `[ssh_http:${response.status}] ${message} (${input.method ?? "GET"} ${requestUrl.toString()})`,
1068 });1007 });
1069 }1008 },
1070 return (yield* response.json.pipe(1009);
1071 Effect.mapError(
1072 (cause) =>
1073 new SshHttpBridgeError({
1074 message: `SSH forwarded endpoint ${requestUrl.toString()} returned invalid JSON.`,
1075 cause,
1076 }),
1077 ),
1078 )) as T;
1079});
10801010
1081const reserveLocalTunnelPort = Effect.fn("ssh/tunnel.reserveLocalTunnelPort")(function* () {1011const reserveLocalTunnelPort = Effect.fn("ssh/tunnel.reserveLocalTunnelPort")(function* () {
1082 const net = yield* NetService.NetService;1012 const net = yield* NetService.NetService;