@dytsou/shorten-url 2.4.4 → 2.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,155 @@
1
+ import { hasPassedAccess } from "./access.js";
2
+ import { createFlagManagement } from "./flag-management.js";
3
+ import { FLAGSHIP_CONTEXT_ATTRIBUTES } from "./feature-toggle.js";
4
+ import { flagEvaluationFields, log } from "./observability.js";
5
+
6
+ const TARGETING_KEY_COOKIE = "shorten-url-targeting-key";
7
+ const TARGETING_KEY_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
8
+ const REQUEST_CONTEXT_ATTRIBUTES = FLAGSHIP_CONTEXT_ATTRIBUTES.filter(
9
+ (attribute) => !["country", "utmSource", "utmMedium", "utmCampaign"].includes(attribute)
10
+ );
11
+ const CAMPAIGN_PARAMETERS = [
12
+ ["utm_source", "utmSource"],
13
+ ["utm_medium", "utmMedium"],
14
+ ["utm_campaign", "utmCampaign"],
15
+ ];
16
+
17
+ function normalizePrefix(prefix) {
18
+ return `/${String(prefix || "").replace(/^\/|\/$/g, "")}`;
19
+ }
20
+
21
+ function originFromPageUrl(pageUrl) {
22
+ try {
23
+ return new URL(pageUrl).origin;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ function isPathWithin(path, prefix) {
30
+ return path === prefix || path.startsWith(`${prefix}/`);
31
+ }
32
+
33
+ function managementRoute(path, apiPrefix) {
34
+ const suffix = path === apiPrefix ? "" : path.slice(`${apiPrefix}/`.length);
35
+ const routes = {
36
+ csrf: "csrf",
37
+ flags: "flags",
38
+ flag: "flag",
39
+ "flag/shorten-routing": "flag:shorten-routing",
40
+ "flag/shorten-routing/publish": "publish:shorten-routing",
41
+ };
42
+ return routes[suffix] || "unknown";
43
+ }
44
+
45
+ function targetingKeyFromRequest(request) {
46
+ const cookie = request.headers.get("Cookie") || "";
47
+ for (const entry of cookie.split(";")) {
48
+ const separator = entry.indexOf("=");
49
+ if (separator < 0 || entry.slice(0, separator).trim() !== TARGETING_KEY_COOKIE) continue;
50
+ const value = entry.slice(separator + 1).trim();
51
+ return TARGETING_KEY_PATTERN.test(value) ? value : null;
52
+ }
53
+ return null;
54
+ }
55
+
56
+ function boundedContextValue(value, maxLength = 128) {
57
+ if (typeof value !== "string") return null;
58
+ const normalized = value.trim();
59
+ return normalized && normalized.length <= maxLength ? normalized : null;
60
+ }
61
+
62
+ function contextFromRequest(request) {
63
+ const context = {};
64
+ const geo = request.cf;
65
+ for (const attribute of REQUEST_CONTEXT_ATTRIBUTES) {
66
+ const value = boundedContextValue(geo?.[attribute], attribute === "timezone" ? 64 : 128);
67
+ if (value) context[attribute] = value;
68
+ }
69
+
70
+ const language = boundedContextValue(
71
+ request.headers.get("Accept-Language")?.split(",", 1)[0]?.split(";", 1)[0]?.toLowerCase(),
72
+ 35
73
+ );
74
+ if (language) context.language = language;
75
+
76
+ const url = new URL(request.url);
77
+ for (const [parameter, attribute] of CAMPAIGN_PARAMETERS) {
78
+ const value = boundedContextValue(url.searchParams.get(parameter));
79
+ if (value) context[attribute] = value;
80
+ }
81
+ return context;
82
+ }
83
+
84
+ function setTargetingKeyCookie(response, targetingKey) {
85
+ const headers = new Headers(response.headers);
86
+ headers.append(
87
+ "Set-Cookie",
88
+ `${TARGETING_KEY_COOKIE}=${targetingKey}; Path=/; Max-Age=31536000; HttpOnly; Secure; SameSite=Lax`
89
+ );
90
+ return new Response(response.body, {
91
+ status: response.status,
92
+ statusText: response.statusText,
93
+ headers,
94
+ });
95
+ }
96
+
97
+ export function createFlagRoutes({
98
+ prefix,
99
+ shorteningPath,
100
+ pageUrl,
101
+ shortener,
102
+ env,
103
+ adapter,
104
+ verifyToken,
105
+ allowedOrigins = [],
106
+ createTargetingKey = () => globalThis.crypto.randomUUID(),
107
+ }) {
108
+ const settingsPrefix = normalizePrefix(prefix);
109
+ const pageOrigin = originFromPageUrl(pageUrl);
110
+ const trustedOrigins = [
111
+ ...new Set(
112
+ [pageOrigin, ...(Array.isArray(allowedOrigins) ? allowedOrigins : [])].filter(Boolean)
113
+ ),
114
+ ];
115
+ const management = createFlagManagement({
116
+ adapter,
117
+ responses: shortener,
118
+ env,
119
+ verifyToken,
120
+ allowedOrigins: trustedOrigins,
121
+ });
122
+ const apiPrefix = `${settingsPrefix}/api`;
123
+
124
+ function handleSettings(request, path) {
125
+ if (!isPathWithin(path, settingsPrefix)) return null;
126
+ if (!isPathWithin(path, apiPrefix)) {
127
+ return shortener.jsonResponse({ status: 404, message: "Settings route not found" }, 404);
128
+ }
129
+ return management.handle(request, managementRoute(path, apiPrefix));
130
+ }
131
+
132
+ async function evaluateShortening(request, path, fallbackResponse) {
133
+ if (request.method !== "GET" || !hasPassedAccess(request, env)) return null;
134
+ if (path !== shorteningPath) return null;
135
+ const country = request.cf?.country;
136
+ const existingTargetingKey = targetingKeyFromRequest(request);
137
+ const targetingKey = existingTargetingKey || createTargetingKey();
138
+ const context = contextFromRequest(request);
139
+ const decision = await adapter.evaluate(country, {
140
+ targetingKey,
141
+ ...(Object.keys(context).length ? { context } : {}),
142
+ });
143
+ log("info", "flagship.evaluation", flagEvaluationFields(decision));
144
+ let response = null;
145
+ if (decision.outcome === "matched") {
146
+ response = Response.redirect(decision.destination, 302);
147
+ } else if (typeof fallbackResponse === "function") {
148
+ response = await fallbackResponse();
149
+ }
150
+ if (!response || existingTargetingKey) return response;
151
+ return setTargetingKeyCookie(response, targetingKey);
152
+ }
153
+
154
+ return { handleSettings, evaluateShortening };
155
+ }
@@ -0,0 +1,269 @@
1
+ import { log } from "./observability.js";
2
+ import {
3
+ SHORTENING_FLAG_KEY,
4
+ FLAGSHIP_CONTEXT_ATTRIBUTES,
5
+ fromProviderFlag,
6
+ normalizeCountry,
7
+ toProviderFlag,
8
+ validateFlagDefinition,
9
+ } from "./feature-toggle.js";
10
+ import { isSafeDestination } from "./safety.js";
11
+
12
+ export class FlagshipUnavailableError extends Error {
13
+ constructor(message = "Flagship is not configured") {
14
+ super(message);
15
+ this.name = "FlagshipUnavailableError";
16
+ }
17
+ }
18
+
19
+ export class FlagshipManagementError extends Error {
20
+ constructor(message, status = 502) {
21
+ super(message);
22
+ this.name = "FlagshipManagementError";
23
+ this.status = status;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Typed boundary for the first-party Cloudflare Flagship binding.
29
+ *
30
+ * @typedef {object} FlagshipBinding
31
+ * @property {(key: string, fallback: object, context?: Record<string, string>) => Promise<object>} getObjectDetails
32
+ *
33
+ * @typedef {object} FlagshipEnvironment
34
+ * @property {FlagshipBinding} [FLAGS] Official Cloudflare Flagship evaluation binding.
35
+ * @property {string} [CLOUDFLARE_ACCOUNT_ID] Account identifier for management calls.
36
+ * @property {string} [FLAGSHIP_APP_ID] Flagship app identifier for management calls.
37
+ * @property {string} [FLAGSHIP_API_TOKEN] Secret token for management calls.
38
+ * @property {string} [FLAGSHIP_CSRF_SECRET] Secret used for settings CSRF tokens.
39
+ */
40
+
41
+ function getBinding(env) {
42
+ const binding = env?.FLAGS;
43
+ return binding && typeof binding.getObjectDetails === "function" ? binding : null;
44
+ }
45
+
46
+ const SUPPORTED_PROVIDER_REASONS = new Set(["TARGETING_MATCH", "SPLIT", "DEFAULT", "DISABLED"]);
47
+ const DEFAULT_PROVIDER_REASONS = new Set(["DEFAULT", "DISABLED"]);
48
+
49
+ function failedProviderDecision(country, fallbackReason, durationMs) {
50
+ return { outcome: "failed", country, fallbackReason, durationMs };
51
+ }
52
+
53
+ function unmatchedProviderDecision(country, fallbackReason, durationMs) {
54
+ return { outcome: "unmatched", country, fallbackReason, durationMs };
55
+ }
56
+
57
+ function hasValidProviderDestination(details) {
58
+ const destination = details.value?.url;
59
+ return Boolean(
60
+ destination && isSafeDestination(destination) && typeof details.variant === "string"
61
+ );
62
+ }
63
+
64
+ function matchedProviderDecision(details, country, durationMs) {
65
+ return {
66
+ outcome: "matched",
67
+ country,
68
+ variant: details.variant,
69
+ destination: details.value.url,
70
+ flagKey: details.flagKey || SHORTENING_FLAG_KEY,
71
+ ...(details.version ? { version: details.version } : {}),
72
+ durationMs,
73
+ };
74
+ }
75
+
76
+ function defaultFallbackReason(reason, country) {
77
+ if (reason === "DISABLED") return "disabled_default_missing";
78
+ if (country) return "no_match";
79
+ return "missing_country";
80
+ }
81
+
82
+ function runtimeDecision(details, country, durationMs) {
83
+ const normalizedCountry = normalizeCountry(country);
84
+ if (!details || typeof details !== "object") {
85
+ return failedProviderDecision(normalizedCountry, "malformed_provider_response", durationMs);
86
+ }
87
+ if (details.errorCode) {
88
+ return failedProviderDecision(normalizedCountry, "provider_error", durationMs);
89
+ }
90
+ const reason = details.reason;
91
+ if (!SUPPORTED_PROVIDER_REASONS.has(reason)) {
92
+ return failedProviderDecision(normalizedCountry, "malformed_provider_response", durationMs);
93
+ }
94
+ if (DEFAULT_PROVIDER_REASONS.has(reason)) {
95
+ if (hasValidProviderDestination(details)) {
96
+ return matchedProviderDecision(details, normalizedCountry, durationMs);
97
+ }
98
+ return unmatchedProviderDecision(
99
+ normalizedCountry,
100
+ defaultFallbackReason(reason, normalizedCountry),
101
+ durationMs
102
+ );
103
+ }
104
+ if (!hasValidProviderDestination(details)) {
105
+ return unmatchedProviderDecision(normalizedCountry, "invalid_provider_value", durationMs);
106
+ }
107
+ return matchedProviderDecision(details, normalizedCountry, durationMs);
108
+ }
109
+
110
+ function providerBase(env) {
111
+ const accountId = env?.CLOUDFLARE_ACCOUNT_ID;
112
+ const appId = env?.FLAGSHIP_APP_ID;
113
+ const token = env?.FLAGSHIP_API_TOKEN;
114
+ if (!accountId || !appId || !token) throw new FlagshipUnavailableError();
115
+ return {
116
+ token,
117
+ url:
118
+ env.FLAGSHIP_API_BASE_URL ||
119
+ `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/flagship/apps/${encodeURIComponent(appId)}/flags`,
120
+ };
121
+ }
122
+
123
+ export function createFlagshipAdapter(
124
+ env,
125
+ { fetchImpl = globalThis.fetch, now = () => Date.now(), evaluationTimeoutMs = 200 } = {}
126
+ ) {
127
+ async function evaluate(
128
+ country,
129
+ { targetingKey, context: requestContext, flagKey = SHORTENING_FLAG_KEY } = {}
130
+ ) {
131
+ const started = now();
132
+ const binding = getBinding(env);
133
+ const normalizedCountry = normalizeCountry(country);
134
+ if (!binding) {
135
+ return runtimeDecision(null, country, now() - started);
136
+ }
137
+ let timeoutId;
138
+ try {
139
+ const providedContext = {};
140
+ for (const attribute of FLAGSHIP_CONTEXT_ATTRIBUTES) {
141
+ if (attribute === "country") continue;
142
+ const value = requestContext?.[attribute];
143
+ if (typeof value === "string" && value.trim()) {
144
+ providedContext[attribute] = value.trim();
145
+ }
146
+ }
147
+ const context = {
148
+ ...(normalizedCountry ? { country: normalizedCountry } : {}),
149
+ ...providedContext,
150
+ ...(typeof targetingKey === "string" && targetingKey ? { targetingKey } : {}),
151
+ };
152
+ const details = await Promise.race([
153
+ binding.getObjectDetails(
154
+ flagKey,
155
+ { url: null },
156
+ Object.keys(context).length ? context : undefined
157
+ ),
158
+ new Promise(
159
+ (_, reject) =>
160
+ (timeoutId = setTimeout(
161
+ () => reject(new Error("Flagship evaluation timed out")),
162
+ evaluationTimeoutMs
163
+ ))
164
+ ),
165
+ ]);
166
+ return runtimeDecision(details, country, now() - started);
167
+ } catch (error) {
168
+ const fallbackReason = error?.message?.includes("timed out") ? "timeout" : "provider_error";
169
+ log("warn", "flagship.evaluate_failed", { fallback_reason: fallbackReason });
170
+ return {
171
+ outcome: "failed",
172
+ country: normalizedCountry,
173
+ fallbackReason,
174
+ durationMs: now() - started,
175
+ };
176
+ } finally {
177
+ clearTimeout(timeoutId);
178
+ }
179
+ }
180
+
181
+ async function managementRequest(path = "", options = {}) {
182
+ const { token, url } = providerBase(env);
183
+ const controller = new AbortController();
184
+ const timeout = setTimeout(() => controller.abort(), 2000);
185
+ let response;
186
+ try {
187
+ response = await fetchImpl(`${url}${path}`, {
188
+ ...options,
189
+ signal: controller.signal,
190
+ headers: {
191
+ accept: "application/json",
192
+ "content-type": "application/json",
193
+ authorization: `Bearer ${token}`,
194
+ ...options.headers,
195
+ },
196
+ });
197
+ } catch {
198
+ throw new FlagshipManagementError("Flagship management request failed", 502);
199
+ } finally {
200
+ clearTimeout(timeout);
201
+ }
202
+ let body = null;
203
+ try {
204
+ body = await response.json();
205
+ } catch {
206
+ // Treat a non-JSON provider response as an unavailable provider.
207
+ }
208
+ if (!response.ok || !body?.success) {
209
+ throw new FlagshipManagementError(
210
+ "Flagship management request failed",
211
+ response.status || 502
212
+ );
213
+ }
214
+ return body.result;
215
+ }
216
+
217
+ async function listFlags() {
218
+ return managementRequest();
219
+ }
220
+
221
+ async function getFlag(key = SHORTENING_FLAG_KEY) {
222
+ return managementRequest(`/${encodeURIComponent(key)}`);
223
+ }
224
+
225
+ async function saveFlag(definition, { key = definition?.key, expectedUpdatedAt } = {}) {
226
+ const validated = validateFlagDefinition(definition);
227
+ if (!validated.ok) {
228
+ throw new FlagshipManagementError(validated.errors.join("; "), 400);
229
+ }
230
+ if (expectedUpdatedAt !== undefined) {
231
+ const current = await getFlag(key);
232
+ if (current?.updated_at !== expectedUpdatedAt) {
233
+ throw new FlagshipManagementError("Flag definition changed; reload before saving", 409);
234
+ }
235
+ }
236
+ const providerFlag = toProviderFlag(validated.value);
237
+ return managementRequest(`/${encodeURIComponent(key)}`, {
238
+ method: "PUT",
239
+ body: JSON.stringify(providerFlag),
240
+ });
241
+ }
242
+
243
+ async function createFlag(definition) {
244
+ const validated = validateFlagDefinition(definition);
245
+ if (!validated.ok) throw new FlagshipManagementError(validated.errors.join("; "), 400);
246
+ return managementRequest("", {
247
+ method: "POST",
248
+ body: JSON.stringify(toProviderFlag(validated.value)),
249
+ });
250
+ }
251
+
252
+ async function publishFlag(definition, expectedUpdatedAt) {
253
+ const validated = validateFlagDefinition({ ...definition, enabled: true });
254
+ if (!validated.ok) throw new FlagshipManagementError(validated.errors.join("; "), 400);
255
+ return saveFlag(validated.value, { expectedUpdatedAt });
256
+ }
257
+
258
+ return {
259
+ evaluate,
260
+ listFlags,
261
+ getFlag,
262
+ saveFlag,
263
+ createFlag,
264
+ publishFlag,
265
+ normalizeProviderFlag: fromProviderFlag,
266
+ };
267
+ }
268
+
269
+ export { runtimeDecision };
@@ -1,5 +1,11 @@
1
1
  import { tracing } from "cloudflare:workers";
2
2
 
3
+ const FLAG_OUTCOMES = new Set(["matched", "unmatched", "unpublished", "failed"]);
4
+
5
+ function boundedString(value, maxLength) {
6
+ return typeof value === "string" && value ? value.slice(0, maxLength) : null;
7
+ }
8
+
3
9
  /** Structured JSON log for Workers Logs indexing. */
4
10
  export function log(level, event, fields = {}) {
5
11
  console.log({ level, event, ts: new Date().toISOString(), ...fields });
@@ -37,6 +43,34 @@ export function requestFields(request) {
37
43
  };
38
44
  }
39
45
 
46
+ /** Return only bounded, non-sensitive fields for a Flagship decision log. */
47
+ export function flagEvaluationFields(decision) {
48
+ const key = boundedString(decision?.flagKey, 64) || "shorten-routing";
49
+ const outcome = FLAG_OUTCOMES.has(decision?.outcome) ? decision.outcome : "failed";
50
+ const variant = boundedString(decision?.variant, 64);
51
+ const country =
52
+ typeof decision?.country === "string" && /^[A-Za-z]{2}$/.test(decision.country)
53
+ ? decision.country.toUpperCase()
54
+ : null;
55
+ const version = boundedString(decision?.version, 128);
56
+ const fallbackReason = boundedString(decision?.fallbackReason, 128);
57
+
58
+ return {
59
+ flagship: {
60
+ key,
61
+ outcome,
62
+ ...(variant ? { variant } : {}),
63
+ ...(country ? { country } : {}),
64
+ ...(version ? { version } : {}),
65
+ ...(fallbackReason ? { fallback_reason: fallbackReason } : {}),
66
+ },
67
+ duration_ms:
68
+ typeof decision?.durationMs === "number" && Number.isFinite(decision.durationMs)
69
+ ? Math.max(0, Math.round(decision.durationMs))
70
+ : null,
71
+ };
72
+ }
73
+
40
74
  function setRequestSpanAttributes(span, request) {
41
75
  if (!span.isTraced) return;
42
76
  const url = new URL(request.url);
@@ -1,14 +1,39 @@
1
1
  import { wantsJson } from "./validate.js";
2
2
 
3
+ function withHostedPageBase(html, url) {
4
+ if (!/<head\b/i.test(html) || /<base\b/i.test(html)) return html;
5
+ let baseUrl;
6
+ try {
7
+ baseUrl = new URL(".", url).href;
8
+ } catch {
9
+ return html;
10
+ }
11
+ const escapedBaseUrl = baseUrl.replaceAll("&", "&amp;").replaceAll('"', "&quot;");
12
+ return html.replace(/<head\b[^>]*>/i, (head) => `${head}<base href="${escapedBaseUrl}">`);
13
+ }
14
+
3
15
  async function fetchHostedPage(url, status = 200) {
16
+ if (!url) {
17
+ return new Response("Page is not configured", {
18
+ status,
19
+ headers: { "content-type": "text/plain;charset=UTF-8" },
20
+ });
21
+ }
4
22
  const upstream = await fetch(url, { redirect: "follow" });
5
- return new Response(await upstream.text(), {
23
+ const html = withHostedPageBase(await upstream.text(), url);
24
+ return new Response(html, {
6
25
  status,
7
26
  headers: { "content-type": "text/html;charset=UTF-8" },
8
27
  });
9
28
  }
10
29
 
11
30
  async function fetchInterstitial(url, destination) {
31
+ if (!url) {
32
+ return new Response("Redirect interstitial is not configured", {
33
+ status: 503,
34
+ headers: { "content-type": "text/plain;charset=UTF-8" },
35
+ });
36
+ }
12
37
  const upstream = await fetch(url);
13
38
  const html = (await upstream.text()).replace(/{Replace}/gm, destination);
14
39
  return new Response(html, { headers: { "content-type": "text/html;charset=UTF-8" } });
@@ -17,9 +42,20 @@ async function fetchInterstitial(url, destination) {
17
42
  export function createResponses({ worker, endpoints }) {
18
43
  function corsHeaders() {
19
44
  if (worker.cors !== "on") return {};
45
+ let allowedOrigin = endpoints.frontendOrigin || "";
46
+ if (!allowedOrigin && endpoints.shortenPage) {
47
+ try {
48
+ allowedOrigin = new URL(endpoints.shortenPage).origin;
49
+ } catch {
50
+ allowedOrigin = "";
51
+ }
52
+ }
53
+ allowedOrigin ||= "*";
20
54
  return {
21
- "Access-Control-Allow-Origin": "*",
22
- "Access-Control-Allow-Methods": "POST, GET, OPTIONS",
55
+ "Access-Control-Allow-Origin": allowedOrigin,
56
+ "Access-Control-Allow-Methods": "POST, GET, PUT, OPTIONS",
57
+ "Access-Control-Allow-Headers": "Content-Type, X-CSRF-Token",
58
+ ...(allowedOrigin === "*" ? {} : { "Access-Control-Allow-Credentials": "true" }),
23
59
  };
24
60
  }
25
61
 
@@ -0,0 +1,90 @@
1
+ const DEFAULT_FRONTEND = {
2
+ url: "",
3
+ pagesBase: "",
4
+ workerOrigin: "",
5
+ displayDomain: null,
6
+ theme: "",
7
+ };
8
+
9
+ const DEFAULT_WORKER = {
10
+ no_ref: "off",
11
+ cors: "on",
12
+ unique_link: true,
13
+ custom_link: true,
14
+ safe_browsing_api_key: "",
15
+ max_custom_slug_length: 50,
16
+ min_random_key_length: 6,
17
+ random_chars: "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678",
18
+ reserved_slugs: [
19
+ "api",
20
+ "admin",
21
+ "www",
22
+ "mail",
23
+ "ftp",
24
+ "localhost",
25
+ "password",
26
+ "help",
27
+ "support",
28
+ "contact",
29
+ "about",
30
+ "settings",
31
+ ],
32
+ };
33
+
34
+ const DEFAULT_CONFIG = {
35
+ frontend: DEFAULT_FRONTEND,
36
+ worker: DEFAULT_WORKER,
37
+ storage: { binding_name: "LINKS" },
38
+ flagship: { binding_name: "FLAGS", app_id: "" },
39
+ };
40
+
41
+ function isObject(value) {
42
+ return value && typeof value === "object" && !Array.isArray(value);
43
+ }
44
+
45
+ function cloneConfig(value) {
46
+ if (Array.isArray(value)) return [...value];
47
+ if (isObject(value)) {
48
+ return Object.fromEntries(
49
+ Object.entries(value).map(([key, entry]) => [key, cloneConfig(entry)])
50
+ );
51
+ }
52
+ return value;
53
+ }
54
+
55
+ function mergeConfig(base, override) {
56
+ const result = cloneConfig(base);
57
+ if (!isObject(override)) return result;
58
+
59
+ for (const [key, value] of Object.entries(override)) {
60
+ result[key] = isObject(value) ? mergeConfig(result[key] || {}, value) : value;
61
+ }
62
+ return result;
63
+ }
64
+
65
+ function envValue(env, name, fallback) {
66
+ return typeof env?.[name] === "string" && env[name] ? env[name] : fallback;
67
+ }
68
+
69
+ /**
70
+ * Build the non-secret Worker configuration from defaults and runtime values.
71
+ * Provider credentials intentionally stay in `env` and are never copied here.
72
+ */
73
+ export function createRuntimeConfig(env = {}, overrides = {}) {
74
+ const config = mergeConfig(DEFAULT_CONFIG, overrides);
75
+
76
+ config.frontend.url = envValue(env, "FRONTEND_URL", config.frontend.url);
77
+ config.frontend.pagesBase = envValue(
78
+ env,
79
+ "FRONTEND_PAGES_BASE",
80
+ config.frontend.pagesBase || config.frontend.url
81
+ );
82
+ config.frontend.workerOrigin = envValue(env, "WORKER_ORIGIN", config.frontend.workerOrigin);
83
+ config.worker.cors = envValue(env, "WORKER_CORS", config.worker.cors);
84
+ config.worker.no_ref = envValue(env, "WORKER_NO_REF", config.worker.no_ref);
85
+ config.flagship.app_id = envValue(env, "FLAGSHIP_APP_ID", config.flagship.app_id);
86
+
87
+ return config;
88
+ }
89
+
90
+ export const defaultConfig = DEFAULT_CONFIG;
package/src/lib/safety.js CHANGED
@@ -1,5 +1,20 @@
1
1
  import { traceSpan } from "./observability.js";
2
2
 
3
+ export function isSafeDestination(url) {
4
+ if (typeof url !== "string") return false;
5
+ try {
6
+ const parsed = new URL(url);
7
+ return (
8
+ (parsed.protocol === "https:" || parsed.protocol === "http:") &&
9
+ !parsed.username &&
10
+ !parsed.password &&
11
+ !parsed.hash
12
+ );
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
3
18
  export async function isUrlSafe(url, worker, endpoints) {
4
19
  return traceSpan("safe_browsing.check", async () => {
5
20
  const body = JSON.stringify({
@@ -5,7 +5,7 @@ import { createResponses } from "./responses.js";
5
5
  import { isUrlSafe } from "./safety.js";
6
6
  import { isValidCustomSlug, isValidUrl } from "./validate.js";
7
7
 
8
- export function createShortener({ worker, endpoints, kv }) {
8
+ export function createShortener({ worker, endpoints, kv, env }) {
9
9
  const responses = createResponses({ worker, endpoints });
10
10
  const kvStore = createKvStore({ worker, kv });
11
11
  const reservedSlugSet = new Set(worker.reserved_slugs.map((s) => s.toLowerCase()));
@@ -55,7 +55,7 @@ export function createShortener({ worker, endpoints, kv }) {
55
55
  return jsonResponse({ status: 404, message: "Invalid API path" }, 404);
56
56
  }
57
57
 
58
- if (requireAccess && !hasPassedAccess(request)) {
58
+ if (requireAccess && !hasPassedAccess(request, env)) {
59
59
  log("warn", "shorten.access_denied", { api: { path: apiPath } });
60
60
  return errorResponse("You must use WARP to shorten the URL", 403, request);
61
61
  }