470 lines
17 KiB
JavaScript
470 lines
17 KiB
JavaScript
const REQUIRED_ENVIRONMENT = [
|
|
"PAPERCLIP_E2E_BASE_URL",
|
|
"PAPERCLIP_E2E_EMAIL",
|
|
"PAPERCLIP_DEV_LOGIN_PASSWORD",
|
|
"PAPERCLIP_API_URL",
|
|
"PAPERCLIP_API_KEY",
|
|
"PAPERCLIP_RUN_ID",
|
|
"PAPERCLIP_TASK_ID",
|
|
];
|
|
|
|
// Agent secret APIs expose access.notion_generic_flow_test_account under this
|
|
// normalized delivery key; the live harness never reads any other binding.
|
|
export const NOTION_SECRET_BINDING_KEY = "generic-flow-test-account";
|
|
|
|
const NOTION_LOGIN_MAIL_DOMAINS = ["notion.so", "makenotion.com"];
|
|
|
|
export class NotionGenericLivePreflightError extends Error {
|
|
constructor(code, details = {}) {
|
|
super(code);
|
|
this.name = "NotionGenericLivePreflightError";
|
|
this.code = code;
|
|
this.details = details;
|
|
}
|
|
}
|
|
|
|
function emailDomain(value) {
|
|
const match = String(value ?? "").match(/@([A-Za-z0-9.-]+)/);
|
|
return match?.[1]?.toLowerCase().replace(/\.$/, "") ?? "";
|
|
}
|
|
|
|
function notionMailDomain(value) {
|
|
const domain = emailDomain(value);
|
|
return NOTION_LOGIN_MAIL_DOMAINS.some((allowed) => domain === allowed || domain.endsWith(`.${allowed}`));
|
|
}
|
|
|
|
export function isFreshNotionVerificationMessage(message, { notBefore }) {
|
|
const timestamp = new Date(message?.timestamp ?? message?.createdAt ?? Number.NaN).getTime();
|
|
const lowerSubject = String(message?.subject ?? "").toLowerCase();
|
|
const challengeSubject = /(?:login|verification|one[-\s]?time|temporary|security).*(?:code|pin)|(?:code|pin).*(?:login|verification|one[-\s]?time|temporary|security)/i.test(lowerSubject);
|
|
return notionMailDomain(message?.from)
|
|
&& challengeSubject
|
|
&& Number.isFinite(timestamp)
|
|
&& timestamp >= new Date(notBefore).getTime();
|
|
}
|
|
|
|
export function notionVerificationAuthenticationPassed(message) {
|
|
const headers = message?.headers && typeof message.headers === "object" && !Array.isArray(message.headers)
|
|
? message.headers
|
|
: {};
|
|
const authentication = Object.entries(headers)
|
|
.filter(([key]) => key.toLowerCase().includes("authentication-results"))
|
|
.map(([, value]) => String(value).toLowerCase())
|
|
.join(" ");
|
|
return authentication.includes("dkim=pass")
|
|
&& (authentication.includes("dmarc=pass")
|
|
|| authentication.includes("spf=pass")
|
|
|| authentication.includes("arc=pass"));
|
|
}
|
|
|
|
export function extractNotionVerificationCode(message) {
|
|
const content = [
|
|
message?.subject,
|
|
message?.extractedText,
|
|
message?.text,
|
|
message?.extractedHtml,
|
|
message?.html,
|
|
]
|
|
.filter((value) => typeof value === "string")
|
|
.join("\n")
|
|
.replace(/<[^>]+>/g, " ");
|
|
const digitSequence = "([0-9](?:[\\s-]?[0-9]){5})";
|
|
const patterns = [
|
|
new RegExp(`(?:login|verification|one[-\\s]?time|temporary|security)[^\\n]{0,40}(?:code|pin)[^0-9]{0,40}${digitSequence}`, "gi"),
|
|
new RegExp(`(?:code|pin)[^0-9]{0,40}${digitSequence}`, "gi"),
|
|
new RegExp(`${digitSequence}[^\\n]{0,40}(?:login|verification|one[-\\s]?time|temporary|security)?[^\\n]{0,20}(?:code|pin)`, "gi"),
|
|
];
|
|
const codes = new Set();
|
|
for (const pattern of patterns) {
|
|
for (const match of content.matchAll(pattern)) {
|
|
const code = match[1].replace(/[^0-9]/g, "");
|
|
if (code.length === 6) codes.add(code);
|
|
}
|
|
}
|
|
for (const label of content.matchAll(/(?:code|pin)/gi)) {
|
|
const window = content.slice(label.index, label.index + 120);
|
|
for (const match of window.matchAll(/(^|[^0-9])([0-9](?:[\s-]?[0-9]){5})(?![0-9])/g)) {
|
|
codes.add(match[2].replace(/[^0-9]/g, ""));
|
|
}
|
|
}
|
|
return codes.size === 1 ? [...codes][0] : null;
|
|
}
|
|
|
|
function requiredValue(environment, key) {
|
|
const value = environment[key];
|
|
return typeof value === "string" ? value.trim() : "";
|
|
}
|
|
|
|
function explicitHttpsOrigin(raw, code) {
|
|
let value;
|
|
try {
|
|
value = new URL(raw);
|
|
} catch {
|
|
throw new NotionGenericLivePreflightError(code);
|
|
}
|
|
if (value.protocol !== "https:"
|
|
|| value.username
|
|
|| value.password
|
|
|| value.search
|
|
|| value.hash
|
|
|| (value.pathname !== "/" && value.pathname !== "/api" && value.pathname !== "/api/")) {
|
|
throw new NotionGenericLivePreflightError(code);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function preflightNotionGenericLive(environment = process.env) {
|
|
const missing = REQUIRED_ENVIRONMENT.filter((key) => requiredValue(environment, key) === "");
|
|
if (missing.length > 0) {
|
|
throw new NotionGenericLivePreflightError("missing_environment", { missing });
|
|
}
|
|
|
|
const base = explicitHttpsOrigin(requiredValue(environment, "PAPERCLIP_E2E_BASE_URL"), "unsafe_base_url");
|
|
const api = explicitHttpsOrigin(requiredValue(environment, "PAPERCLIP_API_URL"), "unsafe_api_url");
|
|
|
|
const email = requiredValue(environment, "PAPERCLIP_E2E_EMAIL");
|
|
if (!email.includes("@")) throw new NotionGenericLivePreflightError("invalid_paperclip_email");
|
|
|
|
return {
|
|
baseUrl: base.origin,
|
|
apiBaseUrl: `${api.origin}/api`,
|
|
callbackUrl: `${base.origin}/api/tools/oauth/callback`,
|
|
paperclipEmail: email,
|
|
paperclipPassword: environment.PAPERCLIP_DEV_LOGIN_PASSWORD,
|
|
agentApiKey: environment.PAPERCLIP_API_KEY,
|
|
runId: environment.PAPERCLIP_RUN_ID,
|
|
taskId: environment.PAPERCLIP_TASK_ID,
|
|
secretBindingKey: NOTION_SECRET_BINDING_KEY,
|
|
};
|
|
}
|
|
|
|
async function responseJson(response, code) {
|
|
try {
|
|
return await response.json();
|
|
} catch {
|
|
throw new NotionGenericLivePreflightError(code);
|
|
}
|
|
}
|
|
|
|
async function fetchWithTimeout(fetchImpl, url, init) {
|
|
try {
|
|
return await fetchImpl(url, { ...init, signal: AbortSignal.timeout(15_000) });
|
|
} catch {
|
|
throw new NotionGenericLivePreflightError("request_failed");
|
|
}
|
|
}
|
|
|
|
export async function prepareNotionGenericLiveSmoke({
|
|
environment = process.env,
|
|
fetchImpl = globalThis.fetch,
|
|
loadBrowser,
|
|
}) {
|
|
const config = preflightNotionGenericLive(environment);
|
|
const healthResponse = await fetchWithTimeout(
|
|
fetchImpl,
|
|
new URL("/api/health", config.baseUrl),
|
|
{ headers: { accept: "application/json" } },
|
|
);
|
|
if (!healthResponse.ok) {
|
|
throw new NotionGenericLivePreflightError("health_http_error", { status: healthResponse.status });
|
|
}
|
|
const health = await responseJson(healthResponse, "health_invalid_json");
|
|
if (health?.status !== "ok") throw new NotionGenericLivePreflightError("health_not_ok");
|
|
|
|
const secretsResponse = await fetchWithTimeout(
|
|
fetchImpl,
|
|
`${config.apiBaseUrl}/agents/me/secrets`,
|
|
{
|
|
headers: {
|
|
accept: "application/json",
|
|
authorization: `Bearer ${config.agentApiKey}`,
|
|
},
|
|
},
|
|
);
|
|
if (!secretsResponse.ok) {
|
|
throw new NotionGenericLivePreflightError("secret_metadata_http_error", { status: secretsResponse.status });
|
|
}
|
|
const secretMetadata = await responseJson(secretsResponse, "secret_metadata_invalid_json");
|
|
const available = Array.isArray(secretMetadata?.secrets)
|
|
&& secretMetadata.secrets.some((entry) => entry?.key === config.secretBindingKey && entry?.delivery === "api");
|
|
if (!available) throw new NotionGenericLivePreflightError("secret_binding_unavailable");
|
|
|
|
return { config, browserModule: await loadBrowser() };
|
|
}
|
|
|
|
export async function fetchNotionTestCredentials(config, fetchImpl = globalThis.fetch) {
|
|
const response = await fetchWithTimeout(
|
|
fetchImpl,
|
|
`${config.apiBaseUrl}/agents/me/secrets/${encodeURIComponent(config.secretBindingKey)}/value`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
accept: "application/json",
|
|
authorization: `Bearer ${config.agentApiKey}`,
|
|
},
|
|
},
|
|
);
|
|
if (!response.ok) {
|
|
throw new NotionGenericLivePreflightError("secret_value_http_error", { status: response.status });
|
|
}
|
|
const body = await responseJson(response, "secret_value_invalid_json");
|
|
if (typeof body?.value !== "string") {
|
|
throw new NotionGenericLivePreflightError("secret_value_missing");
|
|
}
|
|
let credential;
|
|
try {
|
|
credential = JSON.parse(body.value);
|
|
} catch {
|
|
throw new NotionGenericLivePreflightError("secret_value_invalid_shape");
|
|
}
|
|
const username = [credential?.email, credential?.username, credential?.login]
|
|
.find((value) => typeof value === "string" && value.trim());
|
|
const password = typeof credential?.password === "string" ? credential.password : "";
|
|
if (!username || !password) {
|
|
throw new NotionGenericLivePreflightError("secret_value_invalid_shape");
|
|
}
|
|
return { username: username.trim(), password };
|
|
}
|
|
|
|
export function assertAutomaticRegistrationSource(source) {
|
|
if (source !== "cimd" && source !== "dcr") {
|
|
throw new NotionGenericLivePreflightError("unexpected_registration_source");
|
|
}
|
|
return source;
|
|
}
|
|
|
|
export function persistedOAuthStartResult(connection, authorizationUrl) {
|
|
const cleanAuthorizationUrl = typeof authorizationUrl === "string" ? authorizationUrl.trim() : "";
|
|
const oauth = connection?.config?.oauth;
|
|
if (!connection?.id || !cleanAuthorizationUrl || !oauth || typeof oauth !== "object" || Array.isArray(oauth)) {
|
|
return null;
|
|
}
|
|
return {
|
|
connectionId: connection.id,
|
|
authorizationUrl: cleanAuthorizationUrl,
|
|
registrationSource: oauth.clientRegistrationSource ?? null,
|
|
issuer: oauth.issuer ?? null,
|
|
resource: oauth.resource ?? null,
|
|
};
|
|
}
|
|
|
|
function nonNegativeInteger(value) {
|
|
return Number.isInteger(value) && value >= 0;
|
|
}
|
|
|
|
/**
|
|
* Normalize the server's sanitized removal receipt. Partial-flow cleanup may
|
|
* legitimately remove zero installs or credentials; the completed smoke uses
|
|
* the stricter mode to prove the installed/authenticated state was revoked.
|
|
*/
|
|
export function connectionRemovalFacts(receipt, { requireInstalled = false } = {}) {
|
|
if (!receipt || typeof receipt !== "object" || Array.isArray(receipt)) return null;
|
|
const numericFields = [
|
|
"installsRemoved",
|
|
"appProfileBindingsRemoved",
|
|
"credentialRefsCleared",
|
|
"secretsRevoked",
|
|
"secretBindingsRemoved",
|
|
"grantsRevoked",
|
|
"oauthStatesDiscarded",
|
|
"runtimeSlotsStopped",
|
|
];
|
|
if (numericFields.some((key) => !nonNegativeInteger(receipt[key]))) return null;
|
|
const credentialsRemoved = receipt.credentialRefsCleared + receipt.secretsRevoked;
|
|
if (!["absent", "deleted", "archived"].includes(receipt.appProfile)) return null;
|
|
if (requireInstalled && (
|
|
receipt.installsRemoved !== 1
|
|
|| receipt.appProfileBindingsRemoved < 1
|
|
|| credentialsRemoved < 1
|
|
|| !["deleted", "archived"].includes(receipt.appProfile)
|
|
)) return null;
|
|
return {
|
|
credentialsRemoved,
|
|
secretBindingsRemoved: receipt.secretBindingsRemoved,
|
|
grantsRevoked: receipt.grantsRevoked,
|
|
accessBindingsRemoved: receipt.appProfileBindingsRemoved,
|
|
installsRemoved: receipt.installsRemoved,
|
|
oauthStatesDiscarded: receipt.oauthStatesDiscarded,
|
|
runtimeSlotsStopped: receipt.runtimeSlotsStopped,
|
|
appProfile: receipt.appProfile,
|
|
};
|
|
}
|
|
|
|
export function safeEndpointSummary(raw, label) {
|
|
let endpoint;
|
|
try {
|
|
endpoint = new URL(raw);
|
|
} catch {
|
|
throw new NotionGenericLivePreflightError(`unsafe_${label}_endpoint`);
|
|
}
|
|
if (endpoint.protocol !== "https:"
|
|
|| endpoint.username
|
|
|| endpoint.password
|
|
|| endpoint.search
|
|
|| endpoint.hash) {
|
|
throw new NotionGenericLivePreflightError(`unsafe_${label}_endpoint`);
|
|
}
|
|
return { origin: endpoint.origin, path: endpoint.pathname };
|
|
}
|
|
|
|
export function inspectAuthorizationUrl(raw, {
|
|
callbackUrl,
|
|
resource,
|
|
registrationSource,
|
|
baseUrl,
|
|
}) {
|
|
let target;
|
|
try {
|
|
target = new URL(raw);
|
|
} catch {
|
|
throw new NotionGenericLivePreflightError("unsafe_authorization_endpoint");
|
|
}
|
|
if (target.protocol !== "https:" || target.username || target.password || target.hash) {
|
|
throw new NotionGenericLivePreflightError("unsafe_authorization_endpoint");
|
|
}
|
|
const required = ["client_id", "state", "code_challenge", "redirect_uri", "resource"];
|
|
if (required.some((key) => !target.searchParams.get(key))) {
|
|
throw new NotionGenericLivePreflightError("authorization_parameter_missing");
|
|
}
|
|
if (target.searchParams.get("code_challenge_method") !== "S256") {
|
|
throw new NotionGenericLivePreflightError("pkce_s256_missing");
|
|
}
|
|
if (target.searchParams.get("redirect_uri") !== callbackUrl) {
|
|
throw new NotionGenericLivePreflightError("callback_uri_mismatch");
|
|
}
|
|
if (target.searchParams.get("resource") !== resource) {
|
|
throw new NotionGenericLivePreflightError("resource_mismatch");
|
|
}
|
|
if (target.searchParams.get("response_type") !== "code") {
|
|
throw new NotionGenericLivePreflightError("response_type_mismatch");
|
|
}
|
|
if (registrationSource === "cimd") {
|
|
const expectedClientId = new URL("/api/tools/oauth/client-metadata", baseUrl).toString();
|
|
if (target.searchParams.get("client_id") !== expectedClientId) {
|
|
throw new NotionGenericLivePreflightError("cimd_client_id_mismatch");
|
|
}
|
|
}
|
|
return {
|
|
endpoint: { origin: target.origin, path: target.pathname },
|
|
parameters: {
|
|
clientId: true,
|
|
state: true,
|
|
pkceS256: true,
|
|
callbackUri: true,
|
|
resource: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
function parsedJsonString(value) {
|
|
const trimmed = value.trim();
|
|
if (trimmed.length === 0 || trimmed.length > 200_000) return null;
|
|
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
|
for (const candidate of fenced ? [fenced[1], trimmed] : [trimmed]) {
|
|
if (!candidate.startsWith("{") && !candidate.startsWith("[")) continue;
|
|
try {
|
|
return JSON.parse(candidate);
|
|
} catch {
|
|
// A later recursive branch may still contain structured content.
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function extractNotionIdentity(value) {
|
|
const seen = new Set();
|
|
const facts = { workspaceId: null, workspaceName: null, botId: null };
|
|
const visit = (candidate, depth) => {
|
|
if (depth > 12 || candidate === null || candidate === undefined) return;
|
|
if (typeof candidate === "string") {
|
|
const parsed = parsedJsonString(candidate);
|
|
if (parsed !== null) visit(parsed, depth + 1);
|
|
return;
|
|
}
|
|
if (typeof candidate !== "object" || seen.has(candidate)) return;
|
|
seen.add(candidate);
|
|
if (!Array.isArray(candidate)) {
|
|
const workspaceId = candidate.workspace_id ?? candidate.workspaceId;
|
|
const workspaceName = candidate.workspace_name ?? candidate.workspaceName;
|
|
if (!facts.workspaceId && typeof workspaceId === "string" && workspaceId.trim()) facts.workspaceId = workspaceId.trim();
|
|
if (!facts.workspaceName && typeof workspaceName === "string" && workspaceName.trim()) facts.workspaceName = workspaceName.trim();
|
|
if (!facts.botId && candidate.type === "bot" && typeof candidate.id === "string" && candidate.id.trim()) {
|
|
facts.botId = candidate.id.trim();
|
|
}
|
|
}
|
|
for (const child of Array.isArray(candidate) ? candidate : Object.values(candidate)) visit(child, depth + 1);
|
|
};
|
|
visit(value, 0);
|
|
return (facts.workspaceId || facts.botId) && facts.workspaceName ? facts : null;
|
|
}
|
|
|
|
export function parseSanitizedAgentProof(commentBody, expectedIdentity) {
|
|
if (typeof commentBody !== "string") return null;
|
|
const trimmed = commentBody.trim();
|
|
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(fenced ? fenced[1] : trimmed);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
if (Object.keys(parsed).sort().join(",") !== "invocationId,workspaceId,workspaceName") return null;
|
|
if (parsed.workspaceId !== expectedIdentity.workspaceId || parsed.workspaceName !== expectedIdentity.workspaceName) return null;
|
|
if (typeof parsed.invocationId !== "string" || !parsed.invocationId.trim()) return null;
|
|
return {
|
|
workspaceId: parsed.workspaceId,
|
|
workspaceName: parsed.workspaceName,
|
|
invocationId: parsed.invocationId.trim(),
|
|
};
|
|
}
|
|
|
|
export function parseRuntimeAbsenceProof(commentBody, connectionId) {
|
|
if (typeof commentBody !== "string") return null;
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(commentBody.trim());
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
if (Object.keys(parsed).sort().join(",") !== "connectionId,toolPresent") return null;
|
|
return parsed.connectionId === connectionId && parsed.toolPresent === false
|
|
? { connectionId, toolPresent: false }
|
|
: null;
|
|
}
|
|
|
|
const FORBIDDEN_EVIDENCE_KEYS = /(?:password|access[_-]?token|refresh[_-]?token|authorization|cookie|oauth[_-]?code|client[_-]?secret|session)/i;
|
|
const FORBIDDEN_EVIDENCE_TEXT = /(?:authorization:\s*bearer|cookie:|[?&](?:code|state|token|access_token|refresh_token)=)/i;
|
|
|
|
export function assertSanitizedEvidence(value) {
|
|
const seen = new Set();
|
|
const visit = (candidate, path) => {
|
|
if (candidate === null || candidate === undefined) return;
|
|
if (typeof candidate === "string") {
|
|
if (FORBIDDEN_EVIDENCE_TEXT.test(candidate)) throw new Error(`unsafe_evidence_text:${path}`);
|
|
return;
|
|
}
|
|
if (typeof candidate !== "object" || seen.has(candidate)) return;
|
|
seen.add(candidate);
|
|
for (const [key, child] of Object.entries(candidate)) {
|
|
if (FORBIDDEN_EVIDENCE_KEYS.test(key)) throw new Error(`unsafe_evidence_key:${path}.${key}`);
|
|
visit(child, `${path}.${key}`);
|
|
}
|
|
};
|
|
visit(value, "$");
|
|
}
|
|
|
|
export function preflightFailureMessage(error) {
|
|
if (!(error instanceof NotionGenericLivePreflightError)) {
|
|
return "Notion generic live smoke preflight failed.";
|
|
}
|
|
if (error.code === "missing_environment") {
|
|
return `Notion generic live smoke preflight failed: missing ${error.details.missing.join(", ")}.`;
|
|
}
|
|
if (error.code === "health_http_error") {
|
|
return `Notion generic live smoke preflight failed: /api/health returned HTTP ${error.details.status}.`;
|
|
}
|
|
return `Notion generic live smoke preflight failed: ${error.code}.`;
|
|
}
|