Skip to content

Commit 2cffe67

Browse files
juliusmarmingecodex
andcommitted
Merge main into mobile remote connect
Co-authored-by: codex <codex@users.noreply.github.com>
2 parents 4ec9647 + 99efaa0 commit 2cffe67

8 files changed

Lines changed: 427 additions & 62 deletions

File tree

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -641,7 +641,7 @@ jobs:
641641
642642
echo "Deploying hosted web app for $channel_name channel."
643643
deployment_url="$(
644-
bunx vercel@53.1.1 deploy apps/web \
644+
bunx vercel@53.1.1 deploy \
645645
--prod \
646646
--skip-domain \
647647
--yes \

apps/server/src/auth/http.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstab
1414
import { AuthError, ServerAuth } from "./Services/ServerAuth.ts";
1515
import { SessionCredentialService } from "./Services/SessionCredentialService.ts";
1616
import { deriveAuthClientMetadata } from "./utils.ts";
17+
import { browserApiCorsHeaders } from "../httpCors.ts";
1718

1819
export const respondToAuthError = (error: AuthError) =>
1920
Effect.gen(function* () {
@@ -27,7 +28,7 @@ export const respondToAuthError = (error: AuthError) =>
2728
{
2829
error: error.message,
2930
},
30-
{ status: error.status ?? 500 },
31+
{ status: error.status ?? 500, headers: browserApiCorsHeaders },
3132
);
3233
});
3334

@@ -38,7 +39,10 @@ export const authSessionRouteLayer = HttpRouter.add(
3839
const request = yield* HttpServerRequest.HttpServerRequest;
3940
const serverAuth = yield* ServerAuth;
4041
const session = yield* serverAuth.getSessionState(request);
41-
return HttpServerResponse.jsonUnsafe(session, { status: 200 });
42+
return HttpServerResponse.jsonUnsafe(session, {
43+
status: 200,
44+
headers: browserApiCorsHeaders,
45+
});
4246
}),
4347
);
4448

@@ -81,7 +85,10 @@ export const authBootstrapRouteLayer = HttpRouter.add(
8185
deriveAuthClientMetadata({ request }),
8286
);
8387

84-
return yield* HttpServerResponse.jsonUnsafe(result.response, { status: 200 }).pipe(
88+
return yield* HttpServerResponse.jsonUnsafe(result.response, {
89+
status: 200,
90+
headers: browserApiCorsHeaders,
91+
}).pipe(
8592
HttpServerResponse.setCookie(sessions.cookieName, result.sessionToken, {
8693
expires: DateTime.toDate(result.response.expiresAt),
8794
httpOnly: true,
@@ -114,6 +121,7 @@ export const authBearerBootstrapRouteLayer = HttpRouter.add(
114121
);
115122
return HttpServerResponse.jsonUnsafe(result satisfies AuthBearerBootstrapResult, {
116123
status: 200,
124+
headers: browserApiCorsHeaders,
117125
});
118126
}).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
119127
);
@@ -128,6 +136,7 @@ export const authWebSocketTokenRouteLayer = HttpRouter.add(
128136
const result = yield* serverAuth.issueWebSocketToken(session);
129137
return HttpServerResponse.jsonUnsafe(result satisfies AuthWebSocketTokenResult, {
130138
status: 200,
139+
headers: browserApiCorsHeaders,
131140
});
132141
}).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
133142
);

apps/server/src/http.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,20 @@ import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolve
2828
import { ServerAuth } from "./auth/Services/ServerAuth.ts";
2929
import { respondToAuthError } from "./auth/http.ts";
3030
import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";
31+
import {
32+
browserApiCorsAllowedHeaders,
33+
browserApiCorsAllowedMethods,
34+
browserApiCorsHeaders,
35+
} from "./httpCors.ts";
3136

3237
const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600";
3338
const 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>`;
3439
const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
3540
const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
3641

3742
export const browserApiCorsLayer = HttpRouter.cors({
38-
allowedMethods: ["GET", "POST", "OPTIONS"],
39-
allowedHeaders: ["authorization", "b3", "traceparent", "content-type"],
43+
allowedMethods: [...browserApiCorsAllowedMethods],
44+
allowedHeaders: [...browserApiCorsAllowedHeaders],
4045
maxAge: 600,
4146
});
4247

@@ -69,7 +74,10 @@ export const serverEnvironmentRouteLayer = HttpRouter.add(
6974
const descriptor = yield* Effect.service(ServerEnvironment).pipe(
7075
Effect.flatMap((serverEnvironment) => serverEnvironment.getDescriptor),
7176
);
72-
return HttpServerResponse.jsonUnsafe(descriptor, { status: 200 });
77+
return HttpServerResponse.jsonUnsafe(descriptor, {
78+
status: 200,
79+
headers: browserApiCorsHeaders,
80+
});
7381
}),
7482
);
7583

apps/server/src/httpCors.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export const browserApiCorsAllowedMethods = ["GET", "POST", "OPTIONS"] as const;
2+
export const browserApiCorsAllowedHeaders = [
3+
"authorization",
4+
"b3",
5+
"traceparent",
6+
"content-type",
7+
] as const;
8+
9+
export const browserApiCorsHeaders = {
10+
"access-control-allow-origin": "*",
11+
"access-control-allow-methods": browserApiCorsAllowedMethods.join(", "),
12+
"access-control-allow-headers": browserApiCorsAllowedHeaders.join(", "),
13+
} as const;

apps/server/src/server.test.ts

Lines changed: 105 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -813,14 +813,20 @@ const bootstrapBrowserSession = (
813813
};
814814
});
815815

816-
const bootstrapBearerSession = (credential = defaultDesktopBootstrapToken) =>
816+
const bootstrapBearerSession = (
817+
credential = defaultDesktopBootstrapToken,
818+
options?: {
819+
readonly headers?: Record<string, string>;
820+
},
821+
) =>
817822
Effect.gen(function* () {
818823
const bootstrapUrl = yield* getHttpServerUrl("/api/auth/bootstrap/bearer");
819824
const response = yield* Effect.promise(() =>
820825
fetch(bootstrapUrl, {
821826
method: "POST",
822827
headers: {
823828
"content-type": "application/json",
829+
...options?.headers,
824830
},
825831
body: JSON.stringify({
826832
credential,
@@ -896,6 +902,22 @@ const splitHeaderTokens = (value: string | null) =>
896902
.filter((token) => token.length > 0)
897903
.toSorted();
898904

905+
const assertBrowserApiCorsHeaders = (headers: Headers) => {
906+
assert.equal(headers.get("access-control-allow-origin"), "*");
907+
assert.deepEqual(splitHeaderTokens(headers.get("access-control-allow-methods")), [
908+
"GET",
909+
"OPTIONS",
910+
"POST",
911+
]);
912+
assert.deepEqual(splitHeaderTokens(headers.get("access-control-allow-headers")), [
913+
"authorization",
914+
"b3",
915+
"content-type",
916+
"traceparent",
917+
]);
918+
};
919+
const crossOriginClientOrigin = "http://remote-client.test:3773";
920+
899921
const getWsServerUrl = (
900922
pathname = "",
901923
options?: { authenticated?: boolean; credential?: string },
@@ -1017,6 +1039,28 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
10171039
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
10181040
);
10191041

1042+
it.effect("includes CORS headers on public environment descriptor responses", () =>
1043+
Effect.gen(function* () {
1044+
yield* buildAppUnderTest();
1045+
1046+
const url = yield* getHttpServerUrl("/.well-known/t3/environment");
1047+
const response = yield* Effect.promise(() =>
1048+
fetch(url, {
1049+
headers: {
1050+
origin: crossOriginClientOrigin,
1051+
},
1052+
}),
1053+
);
1054+
const body = (yield* Effect.promise(() =>
1055+
response.json(),
1056+
)) as typeof testEnvironmentDescriptor;
1057+
1058+
assert.equal(response.status, 200);
1059+
assertBrowserApiCorsHeaders(response.headers);
1060+
assert.deepEqual(body, testEnvironmentDescriptor);
1061+
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
1062+
);
1063+
10201064
it.effect("reports unauthenticated session state without requiring auth", () =>
10211065
Effect.gen(function* () {
10221066
yield* buildAppUnderTest();
@@ -1140,6 +1184,62 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
11401184
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
11411185
);
11421186

1187+
it.effect("includes CORS headers on remote auth success responses", () =>
1188+
Effect.gen(function* () {
1189+
yield* buildAppUnderTest();
1190+
1191+
const origin = crossOriginClientOrigin;
1192+
const { response: bootstrapResponse, body: bootstrapBody } = yield* bootstrapBearerSession(
1193+
defaultDesktopBootstrapToken,
1194+
{
1195+
headers: { origin },
1196+
},
1197+
);
1198+
1199+
assert.equal(bootstrapResponse.status, 200);
1200+
assertBrowserApiCorsHeaders(bootstrapResponse.headers);
1201+
assert.equal(bootstrapBody.authenticated, true);
1202+
assert.equal(typeof bootstrapBody.sessionToken, "string");
1203+
1204+
const sessionUrl = yield* getHttpServerUrl("/api/auth/session");
1205+
const sessionResponse = yield* Effect.promise(() =>
1206+
fetch(sessionUrl, {
1207+
headers: {
1208+
authorization: `Bearer ${bootstrapBody.sessionToken ?? ""}`,
1209+
origin,
1210+
},
1211+
}),
1212+
);
1213+
const sessionBody = (yield* Effect.promise(() => sessionResponse.json())) as {
1214+
readonly authenticated: boolean;
1215+
readonly sessionMethod?: string;
1216+
};
1217+
1218+
assert.equal(sessionResponse.status, 200);
1219+
assertBrowserApiCorsHeaders(sessionResponse.headers);
1220+
assert.equal(sessionBody.authenticated, true);
1221+
assert.equal(sessionBody.sessionMethod, "bearer-session-token");
1222+
1223+
const wsTokenUrl = yield* getHttpServerUrl("/api/auth/ws-token");
1224+
const wsTokenResponse = yield* Effect.promise(() =>
1225+
fetch(wsTokenUrl, {
1226+
method: "POST",
1227+
headers: {
1228+
authorization: `Bearer ${bootstrapBody.sessionToken ?? ""}`,
1229+
origin,
1230+
},
1231+
}),
1232+
);
1233+
const wsTokenBody = (yield* Effect.promise(() => wsTokenResponse.json())) as {
1234+
readonly token: string;
1235+
};
1236+
1237+
assert.equal(wsTokenResponse.status, 200);
1238+
assertBrowserApiCorsHeaders(wsTokenResponse.headers);
1239+
assert.equal(typeof wsTokenBody.token, "string");
1240+
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
1241+
);
1242+
11431243
it.effect(
11441244
"responds to remote auth websocket-token preflight requests with authorization CORS headers",
11451245
() =>
@@ -1151,26 +1251,15 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
11511251
fetch(wsTokenUrl, {
11521252
method: "OPTIONS",
11531253
headers: {
1154-
origin: "http://192.168.86.35:3773",
1254+
origin: crossOriginClientOrigin,
11551255
"access-control-request-method": "POST",
11561256
"access-control-request-headers": "authorization",
11571257
},
11581258
}),
11591259
);
11601260

11611261
assert.equal(response.status, 204);
1162-
assert.equal(response.headers.get("access-control-allow-origin"), "*");
1163-
assert.deepEqual(splitHeaderTokens(response.headers.get("access-control-allow-methods")), [
1164-
"GET",
1165-
"OPTIONS",
1166-
"POST",
1167-
]);
1168-
assert.deepEqual(splitHeaderTokens(response.headers.get("access-control-allow-headers")), [
1169-
"authorization",
1170-
"b3",
1171-
"content-type",
1172-
"traceparent",
1173-
]);
1262+
assertBrowserApiCorsHeaders(response.headers);
11741263
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
11751264
);
11761265

@@ -1183,7 +1272,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
11831272
fetch(wsTokenUrl, {
11841273
method: "POST",
11851274
headers: {
1186-
origin: "http://192.168.86.35:3773",
1275+
origin: crossOriginClientOrigin,
11871276
},
11881277
}),
11891278
);
@@ -1192,7 +1281,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
11921281
};
11931282

11941283
assert.equal(response.status, 401);
1195-
assert.equal(response.headers.get("access-control-allow-origin"), "*");
1284+
assertBrowserApiCorsHeaders(response.headers);
11961285
assert.equal(body.error, "Authentication required.");
11971286
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
11981287
);

0 commit comments

Comments
 (0)