Dreaded egress problems

I used Rovo Studio and then Github Copilot to build a Forge Automation Action for Jira (well it’s also supposed to work in Confluence, but let’s deal with that later) that takes as input a URL in any of the various forms of Confluence URLs:

It returns the following Smart Values output:

  • {{fetchedConfluencePagelookup.status}}
  • {{fetchedConfluencePagelookup.error}}
  • {{fetchedConfluencePagelookup.pageId}}
  • {{fetchedConfluencePagelookup.pageTitle}}
  • {{fetchedConfluencePagelookup.spaceKey}}
  • {{fetchedConfluencePagelookup.FullURL}}

So, as of Fri Jan 5 at 6:33PM PT, this was working GREAT.

Then, of course. I broke it. My only change was trying to add additional lookups to get Space Owner information.

But I started getting this error:

error: Could not extract a page ID from the URL “https://SITE.atlassian.net/wiki/x/CoDQRQ” (resolved to “https://forge-outbound-proxy.prod.atl-paas.net/egress”)

I checked my manifest.yml’s permissions:

permissions:
  scopes:
    - read:page:confluence
    - read:space:confluence
  external:
    fetch:
      backend:
        - 'SITE.atlassian.net'
        - 'SITE-sandbox.atlassian.net'

I got a lint message saying that the permissions were deprecated. So I changed those to the updated permissions syntax :

        - address: 'SITE.atlassian.net'
        - address: 'SITE-sandbox.atlassian.net'

I’ve tried double-quotes instead of single-quotes. I’ve redeployed. I’ve installed. I’ve upgraded. I’ve uninstalled. I’ve installed again. I’ve logged out. I’ve logged back in. I’ve waited 20 minutes.

Same damn error. It’s been frustrating as hell. Does anyone have any ideas?

For a bit of clarity, I have this function to follow redirects (redirect-resolver-service.ts):

/**
 * Redirect Resolver Service
 *
 * Follows HTTP redirects to obtain the final URL for short/unknown URL formats.
 */
import { fetch } from '@forge/api';

/**
 * A minimal fetch function type for dependency injection in tests.
 */
export type FetchFn = (url: string, init?: { method?: string; redirect?: string }) => Promise<{ url: string }>;

/**
 * The default fetch implementation using Forge's fetch API.
 * In the real Forge runtime, fetch with `redirect: 'follow'` returns a Response
 * with a `.url` property containing the final URL after redirects.
 */
const forgeFetch: FetchFn = async (url, init) => {
  const response = await fetch(url, {
    method: init?.method,
    redirect: init?.redirect as 'follow' | 'manual' | 'error' | undefined,
  });
  // The Forge runtime fetch Response has a `.url` property with the final URL
  return { url: (response as unknown as { url: string }).url ?? url };
};

/**
 * Follows HTTP redirects for a given URL and returns the final resolved URL.
 *
 * Uses Forge's `fetch` with `redirect: 'follow'` to handle server-side redirects,
 * which is essential for resolving Confluence short URLs (e.g. /wiki/x/{shortCode}).
 *
 * @param url - The URL to follow redirects for
 * @param fetchFn - Optional fetch function for testing (defaults to Forge fetch)
 * @returns The final URL after all redirects have been followed
 * @throws Error if the network request fails
 */
export async function resolveRedirects(url: string, fetchFn: FetchFn = forgeFetch): Promise<string> {
  const response = await fetchFn(url, {
    method: 'GET',
    redirect: 'follow',
  });

  return response.url;
}

And here is the resolver/index.ts:

/**
 * Main resolver for the Confluence URL Resolver automation action.
 *
 * This function is invoked by the Automation engine when the
 * "Resolve Confluence Page URL" action executes.
 */
import { extractPageId } from '../domain/services/url-parser-service';
import { resolveRedirects, FetchFn } from '../domain/services/redirect-resolver-service';
import { fetchPageDetails, fetchSpaceDetails } from '../infrastructure/confluence-api/confluence-api-service';

/**
 * Output shape returned by the automation action.
 * status is always present; error and page fields are populated based on outcome.
 */
export interface PageLookupOutput {
  status: 'success' | 'fail';
  error: string | null;
  pageId: string | null;
  pageTitle: string | null;
  pageFullURL: string | null;
  spaceKey: string | null;
}

const ALLOWED_HOSTNAMES = ['MYSITE.atlassian.net', 'MYSITE-sandbox.atlassian.net'];

function fail(message: string): PageLookupOutput {
  console.error(`[resolveConfluenceUrl] Error: ${message}`);
  return { status: 'fail', error: message, pageId: null, pageTitle: null, pageFullURL: null, spaceKey: null };
}

/**
 * Returns an error message if the URL is not a valid Confluence URL, null otherwise.
 */
function validateConfluenceUrl(rawUrl: string): string | null {
  let parsed: URL;
  try {
    parsed = new URL(rawUrl);
  } catch {
    return 'Resolver only works with Confluence';
  }
  if (!ALLOWED_HOSTNAMES.includes(parsed.hostname) || !rawUrl.includes('/wiki/')) {
    return 'Resolver only works with Confluence';
  }
  return null;
}

/**
 * Resolves a Confluence URL and returns page metadata as smart values.
 *
 * The Automation engine passes inputs inside `payload` directly.
 *
 * @param payload - The automation action payload containing `confluenceUrl`
 * @param _context - Unused context parameter (required by Forge function signature)
 * @param fetchFn - Optional fetch function for testing redirect resolution
 * @returns Page metadata object, or an errors object surfaced in the Automation Audit log
 */
export async function resolveConfluenceUrl(
  payload: {
    confluenceUrl?: string;
    inputs?: { confluenceUrl?: string };
    payload?: { confluenceUrl?: string; inputs?: { confluenceUrl?: string } };
  },
  _context?: unknown,
  fetchFn?: FetchFn,
): Promise<PageLookupOutput> {
  console.log('TUNNEL LIVE');

  // Automation payload shape can vary by runtime path.
  const confluenceUrl =
    payload.confluenceUrl ??
    payload.inputs?.confluenceUrl ??
    payload.payload?.confluenceUrl ??
    payload.payload?.inputs?.confluenceUrl;

  if (!confluenceUrl || confluenceUrl.trim() === '') {
    return fail('confluenceUrl is required but was not provided or is empty.');
  }

  const validationError = validateConfluenceUrl(confluenceUrl.trim());
  if (validationError) {
    return fail(validationError);
  }

  try {
    console.log(`[resolveConfluenceUrl] Processing URL: ${confluenceUrl}`);

    // Step 1: Try to extract page ID directly from the URL
    let pageId = extractPageId(confluenceUrl);

    // Step 2: If no page ID found, follow redirects and try again
    if (!pageId) {
      console.log('[resolveConfluenceUrl] No page ID found in URL, following redirects...');
      const resolvedUrl = await resolveRedirects(confluenceUrl, fetchFn);
      console.log(`[resolveConfluenceUrl] Resolved URL: ${resolvedUrl}`);

      pageId = extractPageId(resolvedUrl);

      if (!pageId) {
        return fail(
          `Could not extract a page ID from the URL "${confluenceUrl}" (resolved to "${resolvedUrl}"). ` +
          'Ensure the URL points to a valid Confluence page.',
        );
      }
    }

    console.log(`[resolveConfluenceUrl] Extracted page ID: ${pageId}`);

    // Step 3: Fetch page metadata from Confluence API v2
    const pageDetails = await fetchPageDetails(pageId);
    console.log(`[resolveConfluenceUrl] Page title: ${pageDetails.title}, spaceId: ${pageDetails.spaceId}`);

    // Step 4: Fetch space details to get the space key
    const spaceDetails = await fetchSpaceDetails(pageDetails.spaceId);
    console.log(`[resolveConfluenceUrl] Space key: ${spaceDetails.key}`);

    // Step 5: Construct the full canonical URL
    const pageFullURL = `${pageDetails._links.base}${pageDetails._links.webui}`;

    const result: PageLookupOutput = {
      status: 'success',
      error: null,
      pageId: pageDetails.id,
      pageTitle: pageDetails.title,
      pageFullURL,
      spaceKey: spaceDetails.key,
    };

    console.log('[resolveConfluenceUrl] Result:', JSON.stringify(result));
    return result;
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    return fail(message);
  }
}

Ok, fetch is the wrong strategy, since the redirect won’t work without authentication.

Instead, I found the algorithm for how Tiny Links are generated, and asked Copilot to write a function to decode them.

/**
 * Decodes a Confluence tiny URL ID to a numeric page ID.
 *
 * Tiny IDs are base64-like encoded bytes representing an integer in little-endian
 * byte order. Confluence uses a URL-safe variant where '-' maps to '/'.
 * Some tiny IDs also omit a trailing 'A' before base64 padding, so we restore
 * it when the encoded length would otherwise be invalid.
 *
 * @param tinyId - Tiny URL ID segment from /wiki/x/{tinyId}
 * @returns The decoded numeric page ID as a string, or null when invalid
 */
export function decodeTinyUrlIdToPageId(tinyId: string): string | null {
  if (!tinyId || !/^[A-Za-z0-9_-]+$/.test(tinyId)) {
    return null;
  }

  try {
    // Confluence tiny IDs use '-' where normal base64 uses '/'.
    let base64 = tinyId.replace(/-/g, '/').replace(/_/g, '+');

    // Some IDs are emitted without a trailing 'A' in the unpadded base64 text.
    // A valid base64 payload cannot have length % 4 === 1.
    while (base64.length % 4 === 1) {
      base64 += 'A';
    }

    base64 = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=');
    const bytes = Buffer.from(base64, 'base64');

    if (bytes.length === 0) {
      return null;
    }

    let value = 0n;
    for (let i = 0; i < bytes.length; i += 1) {
      value += BigInt(bytes[i]) << (BigInt(i) * 8n);
    }

    return value.toString();
  } catch {
    return null;
  }
}

References: