Part 3: Rethinking Feedback - Cloudflare Worker Setup


So far, we've discussed why traditional feedback methods fall short in Part 1 and set up a secure GitHub App to allow private issue creation in Part 2. Now, it's time to bridge the gap with a backend. We'll use a Cloudflare Worker to securely receive the feedback from your app and post it directly to GitHub.

The best part? You don't need a fancy server to make this work. All we'll need is a little TypeScript (don't worry - I'm not a JavaScript expert either!), a few configuration files, and some Cloudflare tooling to tie it all together.

Why Cloudflare Workers?

Cloudflare Workers are an excellent choice for this setup because they're free (within reasonable usage), lightweight, secure, and highly scalable. With Cloudflare Workers, you get a lot of power and flexibility, including:

  • Global deployment: Functions are deployed globally, which means minimal latency for users no matter where they are.
  • Secure secrets management: You can rotate and manage secrets securely, making it easy to keep sensitive data safe.
  • Built-in rate limiting: Cloudflare makes it simple to add rate limiting to prevent abuse and protect your backend.
  • Logging and monitoring: You can easily add logging and monitoring to track performance and catch any issues early.
  • Local development: During development, you can run your Workers locally, making it easier to test and iterate.

All of this means you can focus on building your app without worrying about infrastructure overhead.

Step 1: Requirements and Setup

Before we dive into setting up the Cloudflare Worker, let's get your environment ready. You'll need the following:

Install NodeJS and npm

If you don't already have NodeJS and npm installed, head over to the NodeJS website and download the latest version.

Create a Project Folder

Make a new folder for your project. This will keep everything organised.

mkdir github-support
cd github-support

Initialise npm

Run the following to create a new package.json file for your project:

npm init -y

Install Wrangler as a Dev Dependency

Wrangler is the official CLI tool for Cloudflare Workers, and we'll install it as a development dependency in your project.

npm install --save-dev wrangler

Step 2: Set Up the Local Dev Environment

Now that we've got everything installed, let's get the local development environment set up.

Install Wrangler

As mentioned earlier, Wrangler is the CLI tool used to manage your Cloudflare Workers. Since you already installed it as a dev dependency, you can now access it with npx to run commands locally.

npx wrangler dev

This will start the local development server, allowing you to test your Worker in a local environment before deploying it to Cloudflare.

You'll see the output for the web server on: http://localhost:8787

Since we don't have anything built, we can stop the server by pressing x to exit. Once we build it, we'll bring it back up.

Step 3: Initialise the Worker Project

To begin, you'll need to initialise a new Cloudflare Worker project. This can be done with the wrangler init command, specifying the project name and TypeScript support.

Run the following commands:

wrangler init my-feedback-worker --typescript
cd my-feedback-worker

This will generate a basic boilerplate setup for your Cloudflare Worker project, complete with TypeScript support. Once initialised, you should see the following files and directory structure:

├── wrangler.toml: Configuration file for Wrangler.
├── src/index.ts: The main TypeScript entry file for your Worker.

When I initially built my Worker, wrangler init made a wrangler.jsonc file instead of a .toml file. This is okay in normal circumstances, but since we will be using a .dev.vars (same as an .env file) we need to be using the .toml format. I used an LLM to convert it for me since there isn't anything critical for our setup.

Step 4: Store GitHub App Credentials in .dev.vars for Local Development

In the previous part, you created your GitHub App, but now it's time to use the credentials for authenticating your Cloudflare Worker. Specifically, you'll need:

  • App ID
  • Client ID
  • Private Key

You'll store these, along with any other relevant data, securely in a .dev.vars file in your project's root directory.

Retrieve Your Credentials

  • GitHub App ID: You can find this on your GitHub App settings page, listed as App ID.
  • GitHub Client ID: You can find this on your GitHub App settings page, listed as Client ID.
  • GitHub Private Key: After generating the private key in the GitHub App settings, you should have downloaded a .pem file. If you can't find the file, go back to the GitHub App settings and regenerate the key.

Set Up the .dev.vars File

In the root of your project, create a file called .dev.vars to securely store your GitHub App credentials. This file will contain environment variables needed for your Cloudflare Worker.

Here's how the .dev.vars file should look:

GITHUB_APP_ID=12345678
GITHUB_CLIENT_ID=Cl4wnF1shB0unce99
GITHUB_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIE...your_key_here...END PRIVATE KEY-----"
DEBUG=true

Proper Formatting for the Private Key

When you copy the contents of the .pem file (your private key), it will contain line breaks. These breaks need to be replaced with \n to make it work correctly in the .dev.vars file. For example, the private key might look like this in your .pem file:

-----BEGIN PRIVATE KEY-----
MIIE...your_key_here...
-----END PRIVATE KEY-----

But when you insert it into your .dev.vars file, it should be formatted like this:

GITHUB_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIE...your_key_here...\n-----END PRIVATE KEY-----"
Important

Never commit .dev.vars to Git. This file contains sensitive information, and committing it can expose your secrets. To avoid this, make sure you add .dev.vars to your .gitignore file to keep it safe.

This step is crucial for keeping your sensitive data secure while allowing your Cloudflare Worker to interact with the GitHub API. You can now safely move forward with configuring your Worker.

Step 5: Push Secrets to Cloudflare (Optional for Production)

While storing secrets locally in the .dev.vars file is perfect for development, when you're ready to deploy your Cloudflare Worker to production, you'll need to push the secrets securely to Cloudflare.

This step involves using wrangler secret put to securely store your credentials in Cloudflare's environment. This is especially important for keeping secrets out of version control and ensuring they're available during production deploys.

How to Push Your Secrets

To push the secrets to Cloudflare, run the following commands:

GitHub App ID

npx wrangler secret put GITHUB_APP_ID

When prompted, enter the GITHUB_APP_ID and press enter.

GitHub Client ID

npx wrangler secret put GITHUB_CLIENT_ID

When prompted, enter the GITHUB_CLIENT_ID and press enter.

GitHub Private Key

npx wrangler secret put GITHUB_PRIVATE_KEY

When prompted, enter the GITHUB_PRIVATE_KEY, using the one from the .dev.vars and \n, then press enter.

What Gets Stored

When you run the commands, Cloudflare securely stores these secrets and makes them available in your production environment, but they won't be visible in your code.

This way, your credentials are safe and secure, and your Cloudflare Worker can access them without exposing them in your code.

Once the secrets are securely pushed to Cloudflare, your Worker will have everything it needs to authenticate with GitHub without ever exposing sensitive data.

Step 6: Write the Worker Logic

Now that we have the necessary environment set up, it's time to dive into the worker logic. The goal here is simple: we want to receive feedback from your Swift app and create an issue in GitHub.

The Basic Flow

  • Receive a POST request from your Swift app.
  • Parse the form data to extract the necessary information (e.g., title, description, and other details).
  • Create a JWT (JSON Web Token) using your GitHub App's private key.
  • Use the GitHub API to authenticate as the app and obtain an installation-level token.
  • Create an issue in the specified GitHub repository.

Below is the worker logic spread across multiple files. Each file has a specific job, keeping the code modular and easy to maintain.

Project Structure

I had to delete the index.html inside the public/ folder to get the API to work in the end. You might not have to but if you run into issues, you can delete it since we won't have a public webpage to access.

Here's a breakdown of the files in the src/ folder:

src/
  ├── constants.ts
  ├── index.ts
  ├── types.d.ts
  └── utils/
      ├── debug.ts
      ├── GitHub.ts
      ├── jwt.js
      ├── request.ts
      ├── responses.ts
      ├── validate.ts

File Contents

constants.ts

/**
 * Maximum allowed length for the issue title.
 * GitHub currently allows up to 256 characters.
 */
export const MAX_TITLE_LENGTH: number = 256;

/**
 * Maximum allowed length for the issue body/description.
 * GitHub limits issue bodies to 65,536 characters, but we use 65,300 as a safe margin.
 */
export const MAX_DESCRIPTION_LENGTH: number = 65300;

/**
 * Maximum allowed log size in bytes.
 * This is calculated as 10x the max description length to allow large logs,
 * which will be chunked into GitHub comments.
 */
export const MAX_LOG_SIZE: number = MAX_DESCRIPTION_LENGTH * 10;

index.ts

import { parseMultipartFormData, extractTextFromBlob } from './utils/request';
import { validateAndSanitiseFormData } from './utils/validate';
import { generateGitHubJwt } from './utils/jwt';
import { createIssueWithLogs } from './utils/github';
import { errorResponse, jsonResponse } from './utils/responses';
import type { Env, FormRequestBody, IncomingFormData } from './types';
import { debug } from './utils/debug';

/**
 * Cloudflare Worker request handler.
 *
 * Handles a POST request containing multipart form data with JSON metadata
 * and a log file (optionally GZIP compressed). The validated data is used to
 * create a GitHub issue and attach logs as comments.
 */
export default {
 async fetch(request: Request, environment: Env): Promise<Response> {

  // Enable debugging if set in environment
  (globalThis as any).ENV_DEBUG = environment.DEBUG;

  // 1. Reject any non-POST methods
  if (request.method !== 'POST') {
   return errorResponse('Method Not Allowed', 405, { allowedMethods: ['POST'] });
  }

  // 2. Ensure correct Content-Type
  const contentType = request.headers.get('Content-Type') || '';
  if (!contentType.includes('multipart/form-data')) {
   return errorResponse('Unsupported Media Type', 415, {
    expected: 'multipart/form-data'
   });
  }

  // 3. Attempt to parse the multipart form
  let formData: FormData;
  try {
   formData = await request.formData();
   debug('Parsed FormData keys', [...formData.keys()]);
  } catch {
   return errorResponse('Failed to parse multipart form data', 415);
  }

  // 4. Validate FormData fields
  const { requestBody, logsBlob, error } = parseMultipartFormData(formData);
  if (error) return error;

  // 5. Parse the JSON string safely
  let formDataJSON: IncomingFormData;
  try {
   formDataJSON = JSON.parse(requestBody);
   debug('Parsed requestBody JSON', formDataJSON);
  } catch {
   return errorResponse('Invalid JSON in "requestBody"', 400);
  }

  // 6. Read and decompress logs (if needed)
  let logText: string = '';
  if (logsBlob) {
   try {
    logText = await extractTextFromBlob(logsBlob);
    debug('Extracted log text', logText);
   } catch (err) {
    return errorResponse('Failed to read logs', 400, {
     reason: err instanceof Error ? err.message : String(err)
    });
   }
  } else {
   debug('No logsBlob provided; skipping log extraction')
  }

  // 7. Validate and sanitise all data
  let validatedData: FormRequestBody;
  try {
   validatedData = validateAndSanitiseFormData(formDataJSON, logText);
   debug('Validated and sanitised data', validatedData);
  } catch (err) {
   return errorResponse('Validation failed', 422, {
    reason: err instanceof Error ? err.message : String(err)
   });
  }

  // 8. Generate GitHub App JWT and submit issue
  try {
   const githubJWT = await generateGitHubJwt(
    environment.GITHUB_CLIENT_ID,
    environment.GITHUB_PRIVATE_KEY
   );

   const issueResult = await createIssueWithLogs(githubJWT, validatedData);
   debug('GitHub issue created', issueResult);

   return jsonResponse({
    status: 'success',
    message: 'GitHub issue created successfully.',
    url: issueResult.html_url,
    issue: issueResult.issue_number
   }, 201);

  } catch (err) {
   return errorResponse('GitHub issue creation failed', 500, {
    reason: err instanceof Error ? err.message : String(err),
    hint: 'Check API credentials and repo access'
   });
  }
 }
};

types.d.ts

/**
 * Environment variables available to the worker.
 */
export interface Env {
  /** GitHub App ID used for authentication. */
  GITHUB_APP_ID: string;

  /** PEM-encoded GitHub App private key. */
  GITHUB_PRIVATE_KEY: string;

  /** Optional debug flag to enable logging. */
  DEBUG?: string;
}

/**
 * Final, validated and sanitised structure of data to be sent to GitHub.
 */
export interface FormRequestBody {
  /** Unique identifier for the issue/report. */
  id: string;

  /** Title of the GitHub issue. */
  title: string;

  /** Body/description of the GitHub issue. */
  description: string;

  /** Label to assign to the GitHub issue (e.g., 'bug', 'feature'). */
  label: string;

  /** GitHub repository information. */
  repository: Repository;

  /** Optional contact information for the reporter. */
  contact?: Contact;

  /** Metadata about the client environment (app, OS, version, etc). */
  client: ClientMetadata;

  /** Log content to be chunked and attached as comments. */
  logs: string;
}

/**
 * Raw, incoming data from a submitted form.
 * Values are unknown and require validation/sanitisation.
 */
export type IncomingFormData = Partial<{
  /** Unique issue/report ID. */
  id: unknown;

  /** Submitted issue title. */
  title: unknown;

  /** Submitted issue description. */
  description: unknown;

  /** Label/category to apply to the issue. */
  label: unknown;

  /** Target GitHub repository info. */
  repository: Partial<Repository>;

  /** Contact details from the reporter. */
  contact: Partial<Contact>;

  /** App/device information. */
  client: Partial<ClientMetadata>;
}>;

/**
 * GitHub repository target for the issue.
 */
interface Repository {
  /** GitHub username or organisation. */
  username: string;

  /** GitHub repository name. */
  repository: string;
}

/**
 * Optional contact information for the user submitting the issue.
 */
interface Contact {
  /** Full name of the contact. */
  name?: string;

  /** Email address of the contact. */
  email?: string;
}

/**
 * Metadata about the app and device environment at the time of issue.
 */
interface ClientMetadata {
  /** Internal app name (bundle ID-style). */
  appName: string;

  /** User-facing app name. */
  appDisplayName: string;

  /** Version of the app. */
  appVersion: string;

  /** Build number (internal release ID). */
  buildNumber: string;

  /** Platform bundle identifier (iOS/Android/etc). */
  bundleId: string;

  /** Operating system information. */
  currentOS: string;
}

utils/debug.ts

/**
 * Logs debug output to the console only if debugging is enabled.
 * Automatically truncates long string outputs to 200 characters.
 *
 * @param label - Label for the debug output.
 * @param value - The value to log (string, object, etc).
 */
export const debug = (label: string, value?: unknown): void => {
  const isEnabled = (globalThis as any).ENV_DEBUG === 'true';
  if (!isEnabled) return;

  let formatted: string;

  if (typeof value === 'string') {
    const truncated = value.length > 200 ? `${value.slice(0, 200)}... [truncated]` : value;
    formatted = truncated;
  } else if (typeof value === 'number' || typeof value === 'boolean') {
    formatted = String(value);
  } else {
    try {
      const json = JSON.stringify(value, null, 2);
      formatted = json.length > 1000 ? `${json.slice(0, 1000)}... [truncated]` : json;
    } catch {
      formatted = '[Unserialisable Object]';
    }
  }

  console.log(`[DEBUG] ${label}:`, formatted);
};

utils/github.ts

import type { FormRequestBody } from '../types';

/**
 * Creates a GitHub issue and posts log data as comment chunks.
 *
 * @param jwt - A GitHub App JWT for authentication.
 * @param data - Validated form data including metadata and logs.
 * @returns An object containing the GitHub issue URL and issue number.
 * @throws Will throw an error if any API call fails.
 */
export const createIssueWithLogs = async (
  jwt: string,
  data: FormRequestBody
) => {
  /**
   * Retrieves the first installation ID associated with the GitHub App.
   *
   * @returns A GitHub App installation ID.
   * @throws If no installation is found or the API request fails.
   */
  const getInstallationId = async (): Promise<number> => {
    const res = await fetch('https://api.github.com/app/installations', {
      headers: {
        Authorization: `Bearer ${jwt}`,
        Accept: 'application/vnd.github+json',
        'User-Agent': 'mb-github-issue',
      },
    });

    if (!res.ok) {
      const err = await res.text();
      throw new Error(`Could not get installations: ${err}`);
    }

    const installs = await res.json();
    if (!Array.isArray(installs) || installs.length === 0) {
      throw new Error('No installations found.');
    }

    return installs[0].id;
  };

  /**
   * Generates an installation access token to interact with GitHub on behalf of the user.
   *
   * @param installationId - The ID of the GitHub App installation.
   * @returns A GitHub installation access token.
   * @throws If token generation fails.
   */
  const getInstallationToken = async (installationId: number): Promise<string> => {
    const res = await fetch(
      `https://api.github.com/app/installations/${installationId}/access_tokens`,
      {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${jwt}`,
          Accept: 'application/vnd.github+json',
          'User-Agent': 'mb-github-issue',
        },
      }
    );

    if (!res.ok) {
      const err = await res.text();
      throw new Error(`Token request failed: ${err}`);
    }

    const { token } = (await res.json()) as { token: string };
    return token;
  };

  /**
   * Splits a large log string into smaller chunks to comply with GitHub comment limits.
   *
   * @param logs - The full log text.
   * @param max - The maximum size per chunk (default: 60,000 characters).
   * @returns An array of log string chunks.
   */
  const chunkLogs = (logs: string, max = 60000): string[] => {
    const parts: string[] = [];

    while (logs.length > max) {
      let idx = logs.lastIndexOf('\n', max);
      if (idx <= 0) idx = max;
      parts.push(logs.slice(0, idx).trim());
      logs = logs.slice(idx);
    }

    if (logs.trim()) {
      parts.push(logs.trim());
    }

    return parts;
  };

  // --- Begin execution ---

  const installId = await getInstallationId();
  const token = await getInstallationToken(installId);
  const repo = `${data.repository.username}/${data.repository.repository}`;

  // Create the GitHub issue
  const issueRes = await fetch(`https://api.github.com/repos/${repo}/issues`, {
    method: 'POST',
    headers: {
      Authorization: `token ${token}`,
      Accept: 'application/vnd.github+json',
      'User-Agent': 'mb-github-issue',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      title: data.title,
      body: data.description,
      labels: [data.label],
    }),
  });

  if (!issueRes.ok) {
    const err = await issueRes.text();
    throw new Error(`Issue creation failed: ${err}`);
  }

  const issue: { html_url: string; number: number } = await issueRes.json();

  // Post log data as comment chunks, if present
  if (data.logs) {
    const chunks = chunkLogs(data.logs, 60000);

    for (let i = 0; i < chunks.length; i++) {
      const commentBody = `### Log part ${i + 1}/${chunks.length}\n\`\`\`\n${chunks[i]}\n\`\`\``;

      const commentRes = await fetch(
        `https://api.github.com/repos/${repo}/issues/${issue.number}/comments`,
        {
          method: 'POST',
          headers: {
            Authorization: `token ${token}`,
            Accept: 'application/vnd.github+json',
            'User-Agent': 'mb-github-issue',
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({ body: commentBody }),
        }
      );

      if (!commentRes.ok) {
        const err = await commentRes.text();
        throw new Error(`Failed to post log chunk ${i + 1}: ${err}`);
      }
    }
  }

  return {
    html_url: issue.html_url,
    issue_number: issue.number,
  };
};

utils/jwt.js

/**
 * Generates a GitHub App JSON Web Token (JWT) for authentication.
 *
 * This JWT is required to authenticate as a GitHub App in order to
 * obtain installation tokens and perform actions via GitHub's API.
 *
 * @param appId - The GitHub App ID.
 * @param privateKeyPem - The PEM-encoded private key associated with the GitHub App.
 * @returns A signed JWT as a string.
 * @throws If inputs are missing or the PEM format is invalid.
 */
export const generateGitHubJwt = async (appId: string, privateKeyPem: string): Promise<string> => {
  if (!appId || !privateKeyPem) {
    throw new Error('GitHub App credentials missing.');
  }

  const encoder = new TextEncoder();
  const now = Math.floor(Date.now() / 1000);

  // JWT header and payload
  const header = { alg: 'RS256', typ: 'JWT' };
  const payload = {
    iat: now - 60,     // issued at: 60 seconds ago
    exp: now + 600,    // expiration: 10 minutes from now
    iss: appId         // issuer: GitHub App ID
  };

  /**
   * Encodes a Uint8Array to a Base64 URL-safe string.
   */
  const base64url = (data: Uint8Array): string =>
    btoa(String.fromCharCode(...data))
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=+$/, '');

  /**
   * Converts a PEM-formatted string to an ArrayBuffer.
   */
  const pemToArrayBuffer = (pem: string): ArrayBuffer => {
    if (!pem.includes('-----BEGIN') || !pem.includes('-----END')) {
      throw new Error('Invalid PEM format.');
    }

    const b64 = pem
      .replace(/-----BEGIN [^-]+-----/, '')
      .replace(/-----END [^-]+-----/, '')
      .replace(/\s+/g, '');
    const str = atob(b64);
    const buf = new Uint8Array(str.length);
    for (let i = 0; i < str.length; i++) buf[i] = str.charCodeAt(i);
    return buf.buffer;
  };

  /**
   * Imports the PEM private key into a CryptoKey usable for signing.
   */
  const importPrivateKey = async (pem: string): Promise<CryptoKey> =>
    crypto.subtle.importKey(
      'pkcs8',
      pemToArrayBuffer(pem),
      { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
      false,
      ['sign']
    );

  // Create the unsigned JWT
  const encodedHeader = base64url(encoder.encode(JSON.stringify(header)));
  const encodedPayload = base64url(encoder.encode(JSON.stringify(payload)));
  const unsignedToken = `${encodedHeader}.${encodedPayload}`;

  // Sign the token
  const key = await importPrivateKey(privateKeyPem);
  const signature = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', key, encoder.encode(unsignedToken));
  const encodedSignature = base64url(new Uint8Array(signature));

  return `${encodedHeader}.${encodedPayload}.${encodedSignature}`;
};

utils/request.ts

import { errorResponse } from './responses';

/**
 * Parses a multipart/form-data request to extract a `requestBody` JSON string
 * and an optional `logs` file upload.
 *
 * This function:
 * - Ensures that the `requestBody` field exists and is a string.
 * - Optionally retrieves the `logs` file, if it is present and valid.
 * - Returns a structured result with extracted values and an optional error.
 *
 * If the `requestBody` is missing or not a string, an error response is included
 * in the return object. If the `logs` file is missing or invalid, it is simply
 * omitted (`logsBlob` will be `undefined`).
 *
 * @param formData - The FormData object parsed from a multipart request.
 * @returns An object containing:
 *   - `requestBody`: the raw JSON string provided in the form data.
 *   - `logsBlob` (optional): the uploaded logs file as a Blob, if provided.
 *   - `error` (optional): a Response object describing any validation error.
 */
export function parseMultipartFormData(formData: FormData): {
  requestBody: string;
  logsBlob?: Blob;
  error?: Response;
} {
  const requestBody = formData.get('requestBody');
  const logs = formData.get('logs');

  if (!requestBody || typeof requestBody !== 'string') {
    return {
      requestBody: '',
      logsBlob: new Blob(),
      error: errorResponse('Missing or invalid "requestBody"', 400)
    };
  }

  const logsBlob = logs instanceof Blob ? logs : undefined;

  return { requestBody, logsBlob };
}

/**
 * Reads text content from a Blob. If the blob is gzip-compressed, it decompresses it first.
 *
 * @param blob - A Blob that is either plain text or gzip compressed.
 * @returns A promise that resolves with the extracted plain text.
 * @throws If decompression fails or reading the blob fails.
 */
export async function extractTextFromBlob(blob: Blob): Promise<string> {
  if (blob.type === 'application/gzip') {
    const stream = blob.stream().pipeThrough(new DecompressionStream('gzip'));
    const decompressedBlob = await new Response(stream).blob();
    return await decompressedBlob.text();
  }

  return await blob.text();
}

utils/responses.ts

/**
 * Constructs a standard JSON `Response` object.
 *
 * @param body - The response body to be stringified into JSON.
 * @param status - Optional HTTP status code (default is 200).
 * @returns A `Response` with JSON content-type.
 */
export const jsonResponse = (body: object, status: number = 200): Response =>
  new Response(JSON.stringify(body), {
    status,
    headers: { 'Content-Type': 'application/json' }
  });

/**
 * Constructs a standardised error JSON `Response`.
 *
 * @param message - A brief error message to describe the problem.
 * @param status - The HTTP status code to return.
 * @param details - Optional additional key-value pairs for context (e.g. reason, hint).
 * @returns A `Response` with structured JSON error data.
 */
export const errorResponse = (
  message: string,
  status: number,
  details?: object
): Response => {
  return jsonResponse({
    status: 'error',
    error: message,
    ...(details || {})
  }, status);
};

utils/validate.ts

import type { FormRequestBody, IncomingFormData } from '../types';
import { MAX_TITLE_LENGTH, MAX_DESCRIPTION_LENGTH, MAX_LOG_SIZE } from '../constants';

/**
 * Validates and sanitises incoming form data and log text before creating a GitHub issue.
 *
 * @param data - The raw form data received from the client.
 * @param logs - The raw log string extracted or decompressed from uploaded files.
 * @returns A fully validated and sanitised `FormRequestBody` object.
 * @throws If required fields are missing, malformed, or exceed configured limits.
 */
export const validateAndSanitiseFormData = (
  data: IncomingFormData,
  logs: string
): FormRequestBody => {

  /** Checks if the provided string is a valid email format. */
  const isValidEmail = (email: string): boolean =>
    /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);

  /** Escapes HTML special characters in a string to prevent Markdown/HTML injection. */
  const escapeHtml = (text: string): string =>
    text
      .replace(/[<>&]/g, c =>
        c === '<' ? '&lt;' : c === '>' ? '&gt;' : '&amp;'
    )
      .replace(/`/g, '&#96;');

  /** Converts unknown values to a string, defaulting to 'unknown'. */
  const getStringOrUnknown = (value: unknown): string =>
    typeof value === 'string' ? value : 'unknown';

  /** Validates the size of the log string against the configured maximum. */
  const validateLogSize = (logText: string): void => {
    const logSize = new TextEncoder().encode(logText).length;
    if (logSize > MAX_LOG_SIZE) {
      throw new Error(`Log file too large. Max allowed size is ${MAX_LOG_SIZE} bytes.`);
    }
  };

  // --- Validation ---

  if (!data || typeof data !== 'object') throw new Error('Form data must be an object.');

  if (typeof data.title !== 'string') throw new Error('Missing or invalid "title".');
  if (typeof data.description !== 'string') throw new Error('Missing or invalid "description".');
  if (typeof data.label !== 'string') throw new Error('Missing or invalid "label".');

  if (!data.repository || typeof data.repository !== 'object') {
    throw new Error('Missing or invalid "repository" object.');
  }
  if (typeof data.repository.username !== 'string') {
    throw new Error('Missing or invalid "repository.username".');
  }
  if (typeof data.repository.repository !== 'string') {
    throw new Error('Missing or invalid "repository.repository".');
  }

  if (!/^[a-zA-Z\d](?:[a-zA-Z\d]|-(?=[a-zA-Z\d])){0,38}$/.test(data.repository.username)) {
    throw new Error('Invalid GitHub username.');
  }
  if (!/^[\w.-]+$/.test(data.repository.repository)) {
    throw new Error('Invalid GitHub repository name.');
  }

  if (data.contact?.email && typeof data.contact.email === 'string') {
    if (!isValidEmail(data.contact.email)) {
      throw new Error('Invalid email format.');
    }
  }

  if (data.title.length > MAX_TITLE_LENGTH) {
    throw new Error('Title too long.');
  }
  if (data.description.length > MAX_DESCRIPTION_LENGTH) {
    throw new Error('Description too long.');
  }

  validateLogSize(logs);

  // --- Sanitisation ---

  const cleanTitle = escapeHtml(data.title);
  let cleanDescription = escapeHtml(data.description);

  // Contact table
  if (data.contact?.name || data.contact?.email) {
    const safeName = String(data.contact?.name || 'N/A').replace(/\|/g, '');
    const safeEmail = String(data.contact?.email || 'N/A').replace(/\|/g, '');
    const mailtoSubject = encodeURIComponent(`Support - ${data.label}`);
    const mailtoBody = encodeURIComponent(
      `Hi ${safeName},\n\nThanks for contacting us regarding your issue.\n\n[Include details here]`
    );
    const mailtoLink = `mailto:${safeEmail}?subject=${mailtoSubject}&body=${mailtoBody}`;

    cleanDescription += `\n\n---\n\n`;
    cleanDescription += `| Name | Email | Contact |\n|------|-------|---------|\n`;
    cleanDescription += `| ${safeName} | \`${safeEmail}\` | [Reply](${mailtoLink}) |`;
  }

  // Client metadata table
  cleanDescription += `\n\n---\n\n`;
  cleanDescription += `**Client Metadata**\n\n`;
  cleanDescription += `| Field        | Value                    |\n`;
  cleanDescription += `|--------------|--------------------------|\n`;
  cleanDescription += `| App Name     | \`${getStringOrUnknown(data.client?.appName)}\` |\n`;
  cleanDescription += `| Display Name | \`${getStringOrUnknown(data.client?.appDisplayName)}\` |\n`;
  cleanDescription += `| Version      | \`${getStringOrUnknown(data.client?.appVersion)}\` |\n`;
  cleanDescription += `| Build Number | \`${getStringOrUnknown(data.client?.buildNumber)}\` |\n`;
  cleanDescription += `| Bundle ID    | \`${getStringOrUnknown(data.client?.bundleId)}\` |\n`;
  cleanDescription += `| Current OS   | \`${getStringOrUnknown(data.client?.currentOS)}\` |`;

  // --- Final object
  return {
    id: typeof data.id === 'string' ? data.id : crypto.randomUUID(),
    title: cleanTitle,
    description: cleanDescription,
    label: data.label,
    repository: {
      username: data.repository.username,
      repository: data.repository.repository
    },
    contact:
      typeof data.contact?.name === 'string' || typeof data.contact?.email === 'string'
        ? {
          name: typeof data.contact?.name === 'string' ? data.contact.name : '',
          email: typeof data.contact?.email === 'string' ? data.contact.email : ''
        }
        : undefined,
    client: {
      appName: getStringOrUnknown(data.client?.appName),
      appDisplayName: getStringOrUnknown(data.client?.appDisplayName),
      appVersion: getStringOrUnknown(data.client?.appVersion),
      buildNumber: getStringOrUnknown(data.client?.buildNumber),
      bundleId: getStringOrUnknown(data.client?.bundleId),
      currentOS: getStringOrUnknown(data.client?.currentOS)
    },
    logs
  };
};

Step 7: Test the Endpoint Locally

Before deploying, we need to test the worker locally to ensure everything works as expected. This allows you to catch any issues before going live.

Like before, run the dev environment:

npx wrangler dev

By default, the worker will be available at http://localhost:8787. You can send HTTP requests to this endpoint to simulate the feedback form submission.

Test using curl or Postman

Now, you can use curl or a tool like Postman to test your endpoint. You'll need to send a POST request to http://localhost:8787 with the expected form data.

Example curl command:

curl -X POST http://localhost:8787 \
-H "Content-Type: application/json" \
-d '{
      "id": "123",
      "title": "Sample Issue",
      "description": "A description of the issue",
      "label": "bug",
      "repository": {
        "username": "your-github-username",
        "repository": "your-repository"
      },
      "contact": {
        "name": "John Doe",
        "email": "[email protected]"
      },
      "client": {
        "appName": "com.example.myapp",
        "appDisplayName": "My App",
        "appVersion": "1.0.0",
        "buildNumber": "100",
        "bundleId": "com.example.myapp",
        "currentOS": "iOS"
      },
      "logs": "Log data goes here"
    }'

When sending the request, ensure the data matches the format expected by the worker, as outlined in types.d.ts.

If everything is set up correctly - your GitHub App, the local worker, and the request - then you should receive a 201 status code, indicating that the issue has been successfully created.

One thing to remember: update the repository field in the request body to the repository you've authorised in your GitHub App.

Pro Tip

I highly recommend using Postman for testing. It allows you to quickly test various scenarios like sending full requests, testing with missing optional fields (like contact), and verifying edge cases such as large vs. small logs or invalid data. This makes it easier to pinpoint issues and refine the worker logic before deploying to production.

Step 8: Deploy the Worker to Cloudflare

Once you're confident the worker is working locally, it's time to deploy it to Cloudflare.

Simply run:

npx wrangler deploy

You may have to authenticate with Cloudflare if it is the first time, but when successful it will report back the worker URL to use:

https://feedback-worker.yourname.workers.dev

Then test it out and see if the URL works:

curl -X POST https://feedback-worker.yourname.workers.dev \
-H "Content-Type: application/json" \
-d '{
      "id": "123",
      "title": "Sample Issue",
      "description": "A description of the issue",
      "label": "bug",
      "repository": {
        "username": "your-github-username",
        "repository": "your-repository"
      },
      "contact": {
        "name": "John Doe",
        "email": "[email protected]"
      },
      "client": {
        "appName": "com.example.myapp",
        "appDisplayName": "My App",
        "appVersion": "1.0.0",
        "buildNumber": "100",
        "bundleId": "com.example.myapp",
        "currentOS": "iOS"
      },
      "logs": "Log data goes here"
    }'

Step 9: Final Adjustments

Once your worker is deployed and the secrets are pushed, you can start directing your app's feedback form to the worker's public URL, such as:

https://feedback-worker.yourname.workers.dev

However, for a more polished, production-ready setup, you may want to consider customising the URL. If you already own a domain or are open to purchasing one, Cloudflare makes it easy to map your worker to a custom domain. For instance, you could set up a subdomain like:

https://api.example.com

This approach not only looks more professional but also gives you better control over the endpoint's URL. To do this, simply follow Cloudflare's guide on custom domains, and point the subdomain to your deployed worker.

Once you get the hang of it, you can scale this setup by running multiple Workers, each pointing to different domains and GitHub Apps. This allows you to manage all your personal projects under a single .pem key Worker, while keeping different clients, apps, or even silos completely separate. It's a flexible and powerful way to organise your feedback systems.

Summary

You've now built a secure, streamlined backend that:

  • Receives feedback via HTTP requests
  • Authenticates with GitHub using your App credentials
  • Logs issues directly into a private repository
  • Respects your secrets and enforces access control

This setup is perfect for small indie apps or larger teams looking for secure, scalable feedback capture - all with minimal infrastructure overhead.


Enjoyed this content? Fuel my creativity!

If you found this article helpful, you can keep the ideas flowing by supporting me. Buy me a coffee or check out my apps to help me create more content like this!

Coffee Check out my apps