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
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,10 @@ public Response updateScreener(
}

try {
if (request.integrationOrigins() != null) {
screener.setIntegrationOrigins(
Screener.normalizeIntegrationOrigins(request.integrationOrigins()));
}
if (renamed) screenerRepository.renameWorkingScreener(screener);
else screenerRepository.updateWorkingScreener(screener);
return Response.ok(screener, MediaType.APPLICATION_JSON).build();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
package org.codeforphilly.bdt.builder.model.domain;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.ws.rs.BadRequestException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;

@JsonIgnoreProperties(ignoreUnknown = true)
public class Screener {
private static final int MAX_INTEGRATION_ORIGINS = 20;

/* Screener metadata */
private String id;
private String ownerId;
Expand All @@ -19,6 +27,9 @@ public class Screener {
private String publishedScreenerId;
private String lastPublishDate;

/* CRM origins allowed to prefill the published screener and receive its results */
private List<String> integrationOrigins;

public Screener(Map<String, Object> model) {
this.formSchema = model;
}
Expand All @@ -39,6 +50,51 @@ public static String normalizeName(String name) {
return name == null ? "" : name.strip().toLowerCase(java.util.Locale.ROOT);
}

/*
* Browsers compare origins in their serialized form (lowercase, no default
* port, no trailing slash), so store each CRM origin that way.
*/
public static List<String> normalizeIntegrationOrigins(List<String> origins) {
LinkedHashSet<String> normalized = new LinkedHashSet<>();
for (String origin : origins) {
String value = origin == null ? "" : origin.strip();
if (!value.isEmpty()) normalized.add(normalizeOrigin(value));
}
if (normalized.size() > MAX_INTEGRATION_ORIGINS)
throw new BadRequestException(
"Enter at most " + MAX_INTEGRATION_ORIGINS + " CRM origins.");
return new ArrayList<>(normalized);
}

private static String normalizeOrigin(String value) {
BadRequestException invalid = new BadRequestException(
"Enter each CRM origin as https://host or https://host:port, without a path: "
+ value);
URI uri;
try {
uri = new URI(value);
} catch (URISyntaxException e) {
throw invalid;
}
String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT);
String path = uri.getRawPath();
if (value.length() > 200
|| !(scheme.equals("https") || scheme.equals("http"))
|| uri.getHost() == null
|| uri.getRawUserInfo() != null
|| uri.getRawQuery() != null
|| uri.getRawFragment() != null
|| !(path == null || path.isEmpty() || path.equals("/"))) {
throw invalid;
}
int port = uri.getPort();
boolean defaultPort = port == -1
|| (scheme.equals("https") && port == 443)
|| (scheme.equals("http") && port == 80);
return scheme + "://" + uri.getHost().toLowerCase(Locale.ROOT)
+ (defaultPort ? "" : ":" + port);
}

public Map<String, Object> getFormSchema() {
return formSchema;
}
Expand Down Expand Up @@ -91,6 +147,14 @@ public String getLastPublishDate() {
return this.lastPublishDate;
}

public List<String> getIntegrationOrigins() {
return integrationOrigins;
}

public void setIntegrationOrigins(List<String> integrationOrigins) {
this.integrationOrigins = integrationOrigins;
}

public List<BenefitDetail> getBenefits() {
return benefits;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package org.codeforphilly.bdt.builder.model.dto.screener;

import java.util.List;
import org.codeforphilly.bdt.builder.api.validation.AtLeastOneProvided;

@AtLeastOneProvided(fields = {"screenerName"})
public record EditScreenerRequest(String screenerName) {}
@AtLeastOneProvided(fields = {"screenerName", "integrationOrigins"})
public record EditScreenerRequest(String screenerName, List<String> integrationOrigins) {
public EditScreenerRequest(String screenerName) {
this(screenerName, null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.util.Optional;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
Expand Down Expand Up @@ -67,6 +70,34 @@ void unchangedNameSavesWithoutTheUniquenessCheck() throws Exception {
verify(screenerRepository, never()).renameWorkingScreener(any());
}

@Test
void integrationOriginsAreSavedAsBrowserOrigins() throws Exception {
var request = new EditScreenerRequest(null, List.of(
" HTTPS://CRM.Example:443/ ", "http://localhost:4174", "", "https://crm.example"));
try (var response = resource.updateScreener(identity, "screener-1", request)) {
assertEquals(200, response.getStatus());
}
assertEquals(List.of("https://crm.example", "http://localhost:4174"),
screener.getIntegrationOrigins());
verify(screenerRepository).updateWorkingScreener(screener);
}

@Test
void invalidIntegrationOriginsAreRejected() throws Exception {
for (String origin : List.of(
"crm.example", "https://crm.example/path", "https://crm.example?x=1",
"https://user@crm.example", "javascript:alert(1)", "*", "null")) {
var request = new EditScreenerRequest(null, List.of(origin));
try (var response = resource.updateScreener(identity, "screener-1", request)) {
assertEquals(400, response.getStatus(), origin);
assertTrue(((Map<?, ?>) response.getEntity()).get("error").toString()
.contains("CRM origin"), origin);
}
}
assertNull(screener.getIntegrationOrigins());
verify(screenerRepository, never()).updateWorkingScreener(any());
}

@Test
void draftChecksOwnershipBeforeReadingBenefitsOrCallingGemini() throws Exception {
screener.setOwnerId("someone-else");
Expand Down
179 changes: 179 additions & 0 deletions builder-frontend/public/integrations/host.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
// CRMs copy this file as-is, so it stays self-contained instead of importing
// the app's helpers.
function randomId() {
return Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) =>
byte.toString(16).padStart(2, "0"),
).join("");
}

/**
* BDT CRM host adapter. Install the listener before navigating the iframe/popup.
* Copy this module into your CRM's source tree if remote modules are restricted.
* @param {{
* screenerUrl: string,
* getTargetWindow: () => Window | null | undefined,
* initialData: Record<string, unknown>,
* requestId?: string,
* serializeMessages?: boolean,
* onResult: (message: any) => void,
* onError?: (message: any) => void,
* onInitialized?: () => void,
* connectTimeoutMs?: number,
* hostWindow?: Window
* }} options
*
* onError receives `{ code }`: INITIALIZATION_FAILED, CONNECTION_TIMEOUT (no
* screener initialized within connectTimeoutMs), ORIGIN_MISMATCH (the screener
* answered from another origin, such as after a redirect; includes `origin`),
* SCREENER_UNAVAILABLE (the published screener couldn't be loaded),
* ORIGIN_NOT_ALLOWED (the screener's builder hasn't allowed this host's
* origin), or EVALUATION_FAILED.
*/
export function createCrmIntegration({
screenerUrl,
getTargetWindow,
initialData,
requestId = randomId(),
serializeMessages = false,
onResult,
onError = () => {},
onInitialized = () => {},
connectTimeoutMs = 30_000,
hostWindow = window,
}) {
const url = new URL(screenerUrl);
// Allow a base path or proxy prefix before /screener/{id}.
const match = url.pathname.match(/\/screener\/([^/]+)\/?$/);
if (!match || !["http:", "https:"].includes(url.protocol)) {
throw new Error("Expected an absolute published BDT screener URL");
}
if (typeof requestId !== "string" || !requestId || requestId.length > 200) {
throw new Error("Expected a nonempty requestId of at most 200 characters");
}
if (
!initialData ||
typeof initialData !== "object" ||
Array.isArray(initialData)
) {
throw new Error("Expected initialData to be a JSON object");
}
const screenerId = decodeURIComponent(match[1]);
url.searchParams.set("integrationOrigin", hostWindow.location.origin);
// A new launch must reload an existing iframe even when its screener is unchanged.
url.searchParams.set("integrationLaunch", randomId());
let sessionId;
let initialized = false;
let failedSessionId;
let reportedOriginMismatch = false;
const connectTimer = setTimeout(() => {
if (!initialized) onError({ code: "CONNECTION_TIMEOUT" });
}, connectTimeoutMs);
const send = (target, type, payload = {}) => {
const message = {
channel: "bdt.crm",
version: 1,
type,
screenerId,
sessionId,
requestId,
...payload,
};
target.postMessage(
serializeMessages ? JSON.stringify(message) : message,
url.origin,
);
};
const receive = (event) => {
const target = getTargetWindow();
if (!target || event.source !== target) return;
let message = event.data;
if (typeof message === "string") {
try {
message = JSON.parse(message);
} catch {
return;
}
}
if (
!message ||
message.channel !== "bdt.crm" ||
message.version !== 1 ||
message.screenerId !== screenerId ||
typeof message.sessionId !== "string" ||
!message.sessionId
)
return;
if (event.origin !== url.origin) {
// Never send prefill to an unexpected origin, but say why nothing happens.
if (message.type === "ready" && !reportedOriginMismatch) {
reportedOriginMismatch = true;
onError({ code: "ORIGIN_MISMATCH", origin: event.origin });
}
return;
}
if (message.type === "ready") {
// The screener repeats ready until initialized; a new session is a reload.
if (message.sessionId === sessionId && initialized) return;
if (message.sessionId !== sessionId) {
sessionId = message.sessionId;
initialized = false;
}
try {
send(target, "initialize", { inputData: initialData });
} catch (error) {
if (failedSessionId === sessionId) return;
failedSessionId = sessionId;
console.warn("BDT initialization could not be sent", error);
onError({ code: "INITIALIZATION_FAILED" });
}
return;
}
if (message.sessionId !== sessionId || message.requestId !== requestId)
return;
if (message.type === "initialized") {
initialized = true;
clearTimeout(connectTimer);
onInitialized();
} else if (
initialized &&
message.type === "result" &&
message.results &&
typeof message.results === "object" &&
!Array.isArray(message.results) &&
message.inputData &&
typeof message.inputData === "object" &&
!Array.isArray(message.inputData) &&
typeof message.evaluatedAt === "string"
) {
onResult(message);
} else if (
message.type === "error" &&
["SCREENER_UNAVAILABLE", "ORIGIN_NOT_ALLOWED"].includes(message.code)
) {
clearTimeout(connectTimer);
onError(message);
} else if (
initialized &&
message.type === "error" &&
message.code === "EVALUATION_FAILED"
) {
onError(message);
}
};
hostWindow.addEventListener("message", receive);
return {
url: url.href,
dispose() {
clearTimeout(connectTimer);
hostWindow.removeEventListener("message", receive);
// Tell an open screener that its answers no longer reach this host.
const target = getTargetWindow();
if (!sessionId || !target) return;
try {
send(target, "disconnect");
} catch {
// The screener window may already be closed or unreachable.
}
},
};
}
5 changes: 3 additions & 2 deletions builder-frontend/src/api/screener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ export const createNewScreener = async (request: {

export const updateScreener = async (
screenerId: string,
request: { screenerName: string },
) => {
request: { screenerName?: string; integrationOrigins?: string[] },
): Promise<{ integrationOrigins?: string[] }> => {
const url = new URL(`${apiUrl}/screener/${screenerId}`);

try {
Expand All @@ -75,6 +75,7 @@ export const updateScreener = async (
if (!response.ok) {
throw await apiErrorFrom(response, "Could not update the screener");
}
return await response.json();
} catch (error) {
console.error("Error updating screener:", error);
throw error;
Expand Down
Loading
Loading