Public + free downloads coming right up — without poking your admin-only upload routes.

Below are drop-in, full files so Replit can wire this cleanly. You’ll end up with:

Public endpoints (no auth):

GET /api/bp-templates-firebase/list

GET /api/bp-templates-firebase/master

GET /api/bp-templates-firebase/:slug

GET /api/bp-templates-firebase/:slug/download (302 redirect to file)

Admin endpoints stay under your existing requireAdmin mount.

Your Storage rules already allow public reads, so we can use direct download URLs. If you want signed URLs instead, just flip one flag (shown below).

1) Public router (new file)

server/routes/bpTemplatesPublic.ts

import express, { Request, Response } from "express";
import {
  listBusinessPlanTemplates,
  getBusinessPlanTemplateBySlug,
  getMasterBusinessPlanTemplate,
  getPublicDocxUrl,         // direct URL (no signature) -> uses public read
  getSignedDocxUrl          // optional, signed URL (if you switch rules later)
} from "../services/businessPlanTemplates";

const router = express.Router();

// GET /api/bp-templates-firebase/list
router.get("/list", async (_req: Request, res: Response) => {
  try {
    const items = await listBusinessPlanTemplates();
    // Light payload for public list
    const dto = items.map(t => ({
      title: t.title,
      slug: t.slug,
      category: t.category,
      industries: t.industries || t.tags || [],
      isMaster: !!t.isMaster,
      currentVersion: t.currentVersion,
      storagePaths: {
        preview: t.storagePaths?.preview || null,
        thumb: t.storagePaths?.thumb || null
      },
      updatedAt: t.updatedAt || null
    }));
    // cache list briefly
    res.set("Cache-Control", "public, max-age=60");
    res.json(dto);
  } catch (err: any) {
    console.error("[public:list] error", err);
    res.status(500).json({ error: "Failed to list templates" });
  }
});

// GET /api/bp-templates-firebase/master
router.get("/master", async (_req: Request, res: Response) => {
  try {
    const t = await getMasterBusinessPlanTemplate();
    if (!t) return res.status(404).json({ error: "No master template found" });
    res.set("Cache-Control", "public, max-age=60");
    res.json(t);
  } catch (err: any) {
    console.error("[public:master] error", err);
    res.status(500).json({ error: "Failed to resolve master template" });
  }
});

// GET /api/bp-templates-firebase/:slug
router.get("/:slug", async (req: Request, res: Response) => {
  try {
    const t = await getBusinessPlanTemplateBySlug(req.params.slug);
    if (!t) return res.status(404).json({ error: "Template not found" });
    res.set("Cache-Control", "public, max-age=60");
    res.json(t);
  } catch (err: any) {
    console.error("[public:getBySlug] error", err);
    res.status(500).json({ error: "Failed to fetch template" });
  }
});

// GET /api/bp-templates-firebase/:slug/download
// Redirects to a publicly readable URL (or a signed URL if you enable it)
router.get("/:slug/download", async (req: Request, res: Response) => {
  try {
    const t = await getBusinessPlanTemplateBySlug(req.params.slug);
    if (!t) return res.status(404).json({ error: "Template not found" });

    const docxPath = t.storagePaths?.docx;
    if (!docxPath) return res.status(400).json({ error: "Template has no DOCX path" });

    // Toggle this to true if you want signed URLs instead of public direct links
    const USE_SIGNED_URLS = false;

    const url = USE_SIGNED_URLS
      ? await getSignedDocxUrl(docxPath, 15 * 60) // 15 minutes
      : getPublicDocxUrl(docxPath);

    // short cache (URL is versioned by path)
    res.set("Cache-Control", "public, max-age=60");
    return res.redirect(302, url);
  } catch (err: any) {
    console.error("[public:download] error", err);
    res.status(500).json({ error: "Failed to generate download URL" });
  }
});

export default router;

2) Service additions (full file skeleton)

server/services/businessPlanTemplates.ts
(If you already have this, replace with the version below or merge carefully; it uses the Admin SDK + your singleton.)

import { getFirestore } from "firebase-admin/firestore";
import { bucket as adminBucket } from "../firebaseAdmin";

const db = getFirestore();

// Collection paths
const TYPES_COL = "templates/business-plan/types";
const SETTINGS_DOC = "settings/templates.business-plan";

export type StoragePaths = {
  docx?: string;
  preview?: string;
  thumb?: string;
};

export type TemplateDoc = {
  title: string;
  slug: string;
  category: string;
  industries?: string[];
  tags?: string[];
  isActive?: boolean;
  isMaster?: boolean;
  currentVersion?: string;
  storagePaths?: StoragePaths;
  sections?: any[];
  createdAt?: any;
  updatedAt?: any;
};

function docToTemplate(data: FirebaseFirestore.DocumentData, id: string): TemplateDoc {
  return {
    title: data.title,
    slug: data.slug ?? id,
    category: data.category ?? "General",
    industries: data.industries || data.tags || [],
    isActive: data.isActive ?? true,
    isMaster: data.isMaster ?? false,
    currentVersion: data.currentVersion ?? null,
    storagePaths: data.storagePaths || {},
    sections: data.sections || [],
    createdAt: data.createdAt || null,
    updatedAt: data.updatedAt || null
  };
}

// PUBLIC LIST
export async function listBusinessPlanTemplates(): Promise<TemplateDoc[]> {
  const snap = await db.collection(TYPES_COL)
    .where("isActive", "==", true)
    .get();

  return snap.docs.map(d => docToTemplate(d.data(), d.id));
}

// PUBLIC MASTER
export async function getMasterBusinessPlanTemplate(): Promise<TemplateDoc | null> {
  // First try pointer doc for speed
  const pointer = await db.doc(SETTINGS_DOC).get();
  if (pointer.exists) {
    const slug = pointer.get("masterSlug");
    if (slug) {
      const doc = await db.doc(`${TYPES_COL}/${slug}`).get();
      if (doc.exists) return docToTemplate(doc.data()!, doc.id);
    }
  }
  // Fallback scan if pointer missing
  const q = await db.collection(TYPES_COL)
    .where("isMaster", "==", true)
    .limit(1)
    .get();
  if (q.empty) return null;
  const d = q.docs[0];
  return docToTemplate(d.data(), d.id);
}

// PUBLIC GET BY SLUG
export async function getBusinessPlanTemplateBySlug(slug: string): Promise<TemplateDoc | null> {
  const doc = await db.doc(`${TYPES_COL}/${slug}`).get();
  return doc.exists ? docToTemplate(doc.data()!, doc.id) : null;
}

/**
 * Public direct URL builder (no signature needed)
 * Works because your Storage rules allow: allow read: if true;
 * Avoids server CPU and lets CDN cache aggressively.
 */
export function getPublicDocxUrl(storagePath: string): string {
  // Firebase REST form:
  // https://firebasestorage.googleapis.com/v0/b/<bucket>/o/<encodedPath>?alt=media
  const bkt = adminBucket.name; // e.g. "ibrandbiz-bcfbe.firebasestorage.app"
  const encoded = encodeURIComponent(storagePath);
  return `https://firebasestorage.googleapis.com/v0/b/${bkt}/o/${encoded}?alt=media`;
}

/**
 * Signed URL (use if you later lock Storage reads)
 * Requires Google credentials (Admin SDK provides).
 */
export async function getSignedDocxUrl(storagePath: string, ttlSeconds = 900): Promise<string> {
  const file = adminBucket.file(storagePath);
  const expires = Date.now() + ttlSeconds * 1000;
  const [url] = await file.getSignedUrl({
    version: "v4",
    action: "read",
    expires
  });
  return url;
}

3) Mounting (keep admin routes; add public routes)

In your server entry (e.g., server/index.ts or src/index.ts), change mounts to:

import express from "express";
import requireAdmin from "./middleware/requireAdmin"; // your existing middleware
import bpTemplatesPublic from "./routes/bpTemplatesPublic";
import bpTemplatesFirebaseRoutes from "./routes/businessPlanTemplatesFirebase"; // your existing admin routes

const app = express();

// PUBLIC (no auth)
app.use("/api/bp-templates-firebase", bpTemplatesPublic);

// ADMIN (upload/manage only)
app.use("/api/bp-templates-firebase/admin", requireAdmin, bpTemplatesFirebaseRoutes);

// ...rest of your app bootstrap
export default app;


This preserves your existing admin flows and exposes public GET endpoints separately.

4) Quick manual tests
# public master
curl -s http://localhost:5000/api/bp-templates-firebase/master | jq

# public list
curl -s http://localhost:5000/api/bp-templates-firebase/list | jq

# by slug
curl -s http://localhost:5000/api/bp-templates-firebase/business-plan-general-blank-template | jq

# download redirect (should 302)
curl -I http://localhost:5000/api/bp-templates-firebase/business-plan-general-blank-template/download


If you keep USE_SIGNED_URLS=false, the redirect Location will be the public firebasestorage.googleapis.com URL. If you later lock Storage reads, set it to true and the endpoint will return a signed V4 URL instead (no rules change needed then).