Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,8 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Run type tests
run: npm run test:types

- name: Run unit tests
run: npm run test:unit
24 changes: 13 additions & 11 deletions src/modules/connectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function createConnectorsModule(
}

const response = await axios.get<ConnectorAccessTokenResponse>(
`/apps/${appId}/external-auth/tokens/${integrationType}`
`/apps/${appId}/external-auth/tokens/${encodeURIComponent(integrationType)}`
);

// @ts-expect-error
Expand All @@ -61,7 +61,7 @@ export function createConnectorsModule(
}

const response = await axios.get<ConnectorAccessTokenResponse>(
`/apps/${appId}/external-auth/tokens/${integrationType}`
`/apps/${appId}/external-auth/tokens/${encodeURIComponent(integrationType)}`
);

const data = response as unknown as ConnectorAccessTokenResponse;
Expand All @@ -79,7 +79,7 @@ export function createConnectorsModule(
}

const response = await axios.get<ConnectorAccessTokenResponse>(
`/apps/${appId}/external-auth/tokens/connectors/${connectorId}`
`/apps/${appId}/external-auth/tokens/connectors/${encodeURIComponent(connectorId)}`
);

const data = response as unknown as ConnectorAccessTokenResponse;
Expand All @@ -100,7 +100,7 @@ export function createConnectorsModule(
}

const response = await axios.get(
`/apps/${appId}/app-user-auth/connectors/${connectorId}/token`
`/apps/${appId}/app-user-auth/connectors/${encodeURIComponent(connectorId)}/token`
);

const data = response as unknown as { access_token: string };
Expand All @@ -115,7 +115,7 @@ export function createConnectorsModule(
}

const response = await axios.get(
`/apps/${appId}/app-user-auth/connectors/${connectorId}/token`
`/apps/${appId}/app-user-auth/connectors/${encodeURIComponent(connectorId)}/token`
);

const data = response as unknown as ConnectorAccessTokenResponse;
Expand All @@ -130,9 +130,11 @@ export function createConnectorsModule(
request: ConnectorApiRequest
): Promise<ConnectorApiResponse<T>> {
assertNonEmptyString(integrationType, "Integration type");
// Encoded so a runtime-built identifier can only ever select a
// connector, never re-target another route under this token.
return proxyCall<T>(
axios,
`/apps/${appId}/connectors/${integrationType}/call`,
`/apps/${appId}/connectors/${encodeURIComponent(integrationType)}/call`,
request
);
},
Expand Down Expand Up @@ -172,9 +174,9 @@ async function proxyCall<T>(

const response = await axios.post(url, {
method,
// Omitted rather than sent as null so the proxy applies the connector's
// declared default host.
...(request.host === undefined ? {} : { host: request.host }),
// Omitted when unset (undefined or null, since untyped callers write
// either) so the proxy applies the connector's declared default host.
...(request.host == null ? {} : { host: request.host }),
path: request.path,
query: request.query ?? {},
headers: request.headers ?? {},
Expand Down Expand Up @@ -213,7 +215,7 @@ export function createUserConnectorsModule(
}

const response = await axios.post(
`/apps/${appId}/app-user-auth/connectors/${connectorId}/initiate`
`/apps/${appId}/app-user-auth/connectors/${encodeURIComponent(connectorId)}/initiate`
);

const data = response as unknown as { redirect_url: string };
Expand All @@ -226,7 +228,7 @@ export function createUserConnectorsModule(
}

await axios.delete(
`/apps/${appId}/app-user-auth/connectors/${connectorId}`
`/apps/${appId}/app-user-auth/connectors/${encodeURIComponent(connectorId)}`
);
},
};
Expand Down
2 changes: 1 addition & 1 deletion src/modules/connectors.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export interface ConnectorApiResponse<T = unknown> {
* The parsed upstream response body, or proxy error details when no response
* was received. `null` when the response was binary — see {@link dataBase64}.
*/
data: T;
data: T | null;
/**
* The response body base64-encoded, for the media types the connector declares
* as binary (images, PDFs). Set instead of {@link data}, never alongside it.
Expand Down
9 changes: 9 additions & 0 deletions tests/types/connectors.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,17 @@ const rejectsLowercaseMethod = {
path: "/2/tweets",
} satisfies ConnectorApiRequest;

// Even with an explicit type argument, data stays nullable: binary and
// proxy-error responses carry null, so it must be narrowed before use.
declare const typedResponse: ConnectorApiResponse<{ id: string }>;
const narrowableData: { id: string } | null = typedResponse.data;
// @ts-expect-error data may be null until narrowed.
const unnarrowedData: { id: string } = typedResponse.data;

void request;
void response;
void binaryResponse;
void hostedRequest;
void rejectsLowercaseMethod;
void narrowableData;
void unnarrowedData;
27 changes: 26 additions & 1 deletion tests/unit/connectors-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ describe("Connectors module – metered connector proxy", () => {
beforeEach(() => {
base44 = createClient({ serverUrl, appId, serviceToken });
scope = nock(serverUrl);
nock.disableNetConnect();
});

afterEach(() => {
nock.cleanAll();
nock.enableNetConnect();
});

const proxyResponse = {
Expand Down Expand Up @@ -51,6 +53,23 @@ describe("Connectors module – metered connector proxy", () => {
expect(received.headers).toEqual({});
});

test("percent-encodes the integration type so it stays on the connectors route", async () => {
// The route carries the service-role token, so a runtime-built identifier
// containing slashes must select a (nonexistent) connector, not another route.
scope
.post(
`/api/apps/${appId}/connectors/${encodeURIComponent("../evil/route")}/call`
)
.reply(200, proxyResponse);

const res = await base44.asServiceRole.connectors.callApi(
"../evil/route" as any,
{ path: "/x" }
);

expect(res.success).toBe(true);
});

test("forwards a named host, and omits it entirely when unset", async () => {
// The payload is built field by field, so anything not explicitly forwarded
// is silently dropped — which is what happened to `host` before this.
Expand All @@ -60,7 +79,7 @@ describe("Connectors module – metered connector proxy", () => {
bodies.push(body);
return true;
})
.twice()
.times(3)
.reply(200, proxyResponse);

await base44.asServiceRole.connectors.callApi("googlemaps", {
Expand All @@ -70,10 +89,16 @@ describe("Connectors module – metered connector proxy", () => {
await base44.asServiceRole.connectors.callApi("googlemaps", {
path: "/maps/api/geocode/json",
});
await base44.asServiceRole.connectors.callApi("googlemaps", {
host: null as any,
path: "/maps/api/geocode/json",
});

expect(bodies[0].host).toBe("places");
// Absent rather than null, so the proxy picks the connector's default host.
expect("host" in bodies[1]).toBe(false);
// Untyped callers write `host: x ?? null`; null must mean unset, not a host.
expect("host" in bodies[2]).toBe(false);
});

test("maps a binary response to dataBase64 + contentType", async () => {
Expand Down
Loading