File size: 4,530 Bytes
253e783
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import type { CloudflareAccessCredentials } from "../../packages/gateway-client/src/cloudflare-access.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
  coerceSecretRef,
  normalizeSecretInputString,
  type SecretInput,
} from "../config/types.secrets.js";
import { registerSecretValueForRedaction } from "../logging/secret-redaction-registry.js";
import { materializeSecretInput } from "../secrets/resolve-secret-input-string.js";

const CF_ACCESS_CLIENT_ID_ENV = "CF_ACCESS_CLIENT_ID";
const CF_ACCESS_CLIENT_SECRET_ENV = "CF_ACCESS_CLIENT_SECRET";

export type NodeHostCloudflareAccessConfig = {
  clientId: SecretInput;
  clientSecret: SecretInput;
};

function normalizeCloudflareAccessSecretInput(value: unknown, path: string): SecretInput {
  const ref = coerceSecretRef(value);
  if (ref) {
    return ref;
  }
  const literal = normalizeSecretInputString(value);
  if (literal) {
    return literal;
  }
  throw new Error(`invalid node-host ${path}: expected a non-empty SecretInput`);
}

export function normalizeNodeHostCloudflareAccessConfig(
  value: unknown,
): NodeHostCloudflareAccessConfig | undefined {
  if (value === undefined || value === null) {
    return undefined;
  }
  if (
    !isRecord(value) ||
    Object.keys(value).length !== 2 ||
    !("clientId" in value) ||
    !("clientSecret" in value)
  ) {
    throw new Error(
      "invalid node-host gateway.cloudflareAccess: expected clientId and clientSecret",
    );
  }
  return {
    clientId: normalizeCloudflareAccessSecretInput(
      value.clientId,
      "gateway.cloudflareAccess.clientId",
    ),
    clientSecret: normalizeCloudflareAccessSecretInput(
      value.clientSecret,
      "gateway.cloudflareAccess.clientSecret",
    ),
  };
}

/** Persist conventional environment fallback as refs, never as copied plaintext. */
export function nodeHostCloudflareAccessConfigFromEnv(
  env: NodeJS.ProcessEnv,
): NodeHostCloudflareAccessConfig | undefined {
  const clientId = normalizeSecretInputString(env[CF_ACCESS_CLIENT_ID_ENV]);
  const clientSecret = normalizeSecretInputString(env[CF_ACCESS_CLIENT_SECRET_ENV]);
  if (!clientId && !clientSecret) {
    return undefined;
  }
  if (!clientId || !clientSecret) {
    throw new Error(
      `${CF_ACCESS_CLIENT_ID_ENV} and ${CF_ACCESS_CLIENT_SECRET_ENV} must be configured together`,
    );
  }
  return {
    clientId: { source: "env", provider: "default", id: CF_ACCESS_CLIENT_ID_ENV },
    clientSecret: { source: "env", provider: "default", id: CF_ACCESS_CLIENT_SECRET_ENV },
  };
}

export async function resolveNodeHostCloudflareAccess(params: {
  value?: NodeHostCloudflareAccessConfig;
  config: OpenClawConfig;
  env: NodeJS.ProcessEnv;
}): Promise<CloudflareAccessCredentials | undefined> {
  if (!params.value) {
    return undefined;
  }
  const [clientId, clientSecret] = await Promise.all([
    materializeSecretInput({
      config: params.config,
      value: params.value.clientId,
      env: params.env,
    }),
    materializeSecretInput({
      config: params.config,
      value: params.value.clientSecret,
      env: params.env,
    }),
  ]);
  if (!clientId || !clientSecret) {
    throw new Error("node-host Cloudflare Access credentials resolved empty");
  }
  registerSecretValueForRedaction(clientId);
  registerSecretValueForRedaction(clientSecret);
  return { clientId, clientSecret };
}

export function nodeHostGatewayMatchesUrl(
  gateway: { host?: string; port?: number; tls?: boolean },
  target: URL,
): boolean {
  const host = gateway.host ?? "127.0.0.1";
  const urlHost =
    host.includes(":") && !(host.startsWith("[") && host.endsWith("]")) ? `[${host}]` : host;
  const protocol = gateway.tls ? "https:" : "http:";
  const port = gateway.port ?? (gateway.tls ? 443 : 80);
  const configured = new URL(`${protocol}//${urlHost}:${port}`);
  return configured.protocol === target.protocol && configured.host === target.host;
}

export function nodeHostGatewaysShareOrigin(
  left: { host?: string; port?: number; tls?: boolean },
  right: { host?: string; port?: number; tls?: boolean },
): boolean {
  const host = right.host ?? "127.0.0.1";
  const urlHost =
    host.includes(":") && !(host.startsWith("[") && host.endsWith("]")) ? `[${host}]` : host;
  const protocol = right.tls ? "https:" : "http:";
  const port = right.port ?? (right.tls ? 443 : 80);
  return nodeHostGatewayMatchesUrl(left, new URL(`${protocol}//${urlHost}:${port}`));
}