Skip to content

Markdown Editor — Cloud Run Backend Architecture

Date: 2026-01-30
Status: 📋 Proposal
Related: Admin Portal, Firebase Auth, GitHub Integration


Overview

A self-hosted, StackEdit-like Markdown editor integrated into the React admin portal. The backend runs on Cloud Run and enables non-GitHub users to edit documentation via a GitHub App with PR-based workflows.

┌─────────────────────────────────────────────────────────────────────────────┐
│                              ARCHITECTURE                                    │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  ┌──────────────┐     ┌──────────────────┐     ┌─────────────────────────┐ │
│  │   React      │────▶│   Cloud Run      │────▶│   GitHub API           │ │
│  │   Admin      │     │   (Node.js)      │     │   (via GitHub App)     │ │
│  │   Portal     │◀────│                  │◀────│                        │ │
│  └──────────────┘     └──────────────────┘     └─────────────────────────┘ │
│        │                      │                                             │
│        │ Firebase ID Token    │ Validates token                            │
│        ▼                      ▼                                             │
│  ┌──────────────┐     ┌──────────────────┐                                 │
│  │   Firebase   │     │   Firestore      │                                 │
│  │   Auth       │     │   (user roles)   │                                 │
│  └──────────────┘     └──────────────────┘                                 │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

Recommendation: Single Service Architecture

Why single service over microservices:

FactorSingle ServiceMultiple Services
Complexity✅ Lower❌ Higher
Cold starts✅ One service to warm❌ Multiple cold starts
Deployment✅ Simpler CI/CD❌ Coordinated deploys
Cost✅ Lower at small scale❌ Higher fixed costs
Latency✅ No inter-service calls❌ Network hops

For a documentation editor with modest traffic, a single well-structured Node.js service is optimal. You can split later if specific bottlenecks emerge.


Service Structure

cloud-run-docs-api/
├── src/
│   ├── index.js                 # Express app entry
│   ├── middleware/
│   │   ├── auth.js              # Firebase token validation
│   │   ├── rbac.js              # Role-based access control
│   │   └── rateLimiter.js       # Rate limiting
│   ├── routes/
│   │   ├── documents.js         # CRUD for markdown files
│   │   ├── branches.js          # Branch/PR management
│   │   └── health.js            # Health checks
│   ├── services/
│   │   ├── github.js            # GitHub App client
│   │   ├── pathValidator.js     # Allowlist enforcement
│   │   └── conflictResolver.js  # SHA conflict detection
│   └── config/
│       ├── allowedPaths.js      # Path allowlist config
│       └── roles.js             # Role definitions
├── Dockerfile
├── package.json
└── cloudbuild.yaml

API Endpoints

Documents API

MethodEndpointDescriptionAuth Required
GET/api/documentsList all docs in /docsviewer+
GET/api/documents/:pathGet file content + SHAviewer+
POST/api/documents/:pathCreate new file (via PR)editor+
PUT/api/documents/:pathUpdate file (via PR)editor+
DELETE/api/documents/:pathDelete file (via PR)admin

Branch/PR API

MethodEndpointDescriptionAuth Required
GET/api/branchesList user's draft brancheseditor+
POST/api/branchesCreate draft brancheditor+
GET/api/branches/:name/diffPreview changeseditor+
POST/api/branches/:name/prOpen PR from brancheditor+
DELETE/api/branches/:nameAbandon draft brancheditor+

Health/Admin API

MethodEndpointDescriptionAuth Required
GET/healthLiveness probeNone
GET/readyReadiness probeNone
GET/api/config/pathsGet allowed pathsadmin

Request/Response Schemas

GET /api/documents/:path

json
// Response 200
{
  "path": "docs/features/lanterns/README.md",
  "content": "# Lanterns Feature\n\n...",
  "sha": "abc123def456...",
  "lastModified": "2026-01-30T10:00:00Z",
  "lastModifiedBy": "github-username-or-app"
}

PUT /api/documents/:path

json
// Request
{
  "content": "# Updated Content\n\n...",
  "sha": "abc123def456...",          // Required: prevents conflicts
  "commitMessage": "Update lanterns docs",
  "createPr": true,                  // true = PR workflow, false = direct commit
  "branchName": "docs/update-lanterns" // Optional: reuse existing branch
}

// Response 200 (PR created)
{
  "success": true,
  "pr": {
    "number": 42,
    "url": "https://github.com/owner/repo/pull/42",
    "branch": "docs/update-lanterns"
  }
}

// Response 409 (conflict)
{
  "error": "CONFLICT",
  "message": "File was modified since you loaded it",
  "currentSha": "xyz789...",
  "yourSha": "abc123..."
}

GitHub App Authentication Flow

Setup (One-time)

  1. Create GitHub App at github.com/settings/apps/new:

    • Name: Lantern Docs Editor
    • Permissions:
      • Contents: Read & Write
      • Pull Requests: Read & Write
      • Metadata: Read-only
    • Install on target repository
  2. Store credentials in Secret Manager:

    GITHUB_APP_ID=123456
    GITHUB_APP_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----...
    GITHUB_APP_INSTALLATION_ID=12345678

Runtime Auth Flow

┌─────────────────────────────────────────────────────────────────────────────┐
│                        GITHUB APP AUTH FLOW                                  │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  1. Cloud Run loads App private key from Secret Manager                     │
│                           │                                                 │
│                           ▼                                                 │
│  2. Generate JWT (signed with private key, expires in 10 min)              │
│                           │                                                 │
│                           ▼                                                 │
│  3. POST /app/installations/:id/access_tokens                              │
│     Authorization: Bearer <JWT>                                             │
│                           │                                                 │
│                           ▼                                                 │
│  4. Receive Installation Access Token (expires in 1 hour)                  │
│                           │                                                 │
│                           ▼                                                 │
│  5. Use token for GitHub API calls                                         │
│     Authorization: Bearer <installation_access_token>                       │
│                                                                             │
│  6. Cache token, refresh before expiry                                      │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

Implementation (Node.js)

javascript
// src/services/github.js
import { createAppAuth } from '@octokit/auth-app';
import { Octokit } from '@octokit/rest';

class GitHubService {
  constructor() {
    this.installationId = process.env.GITHUB_APP_INSTALLATION_ID;
    this.octokit = null;
    this.tokenExpiry = null;
  }

  async getClient() {
    // Return cached client if token still valid (with 5 min buffer)
    if (this.octokit && this.tokenExpiry > Date.now() + 300000) {
      return this.octokit;
    }

    const auth = createAppAuth({
      appId: process.env.GITHUB_APP_ID,
      privateKey: process.env.GITHUB_APP_PRIVATE_KEY,
      installationId: this.installationId,
    });

    const { token, expiresAt } = await auth({ type: 'installation' });
    
    this.tokenExpiry = new Date(expiresAt).getTime();
    this.octokit = new Octokit({ auth: token });
    
    return this.octokit;
  }

  async getFile(owner, repo, path, ref = 'main') {
    const client = await this.getClient();
    const { data } = await client.repos.getContent({
      owner,
      repo,
      path,
      ref,
    });
    
    return {
      content: Buffer.from(data.content, 'base64').toString('utf-8'),
      sha: data.sha,
      path: data.path,
    };
  }

  async createOrUpdateFile(owner, repo, path, content, sha, message, branch) {
    const client = await this.getClient();
    
    return client.repos.createOrUpdateFileContents({
      owner,
      repo,
      path,
      message,
      content: Buffer.from(content).toString('base64'),
      sha, // Required for updates, prevents conflicts
      branch,
    });
  }

  async createPullRequest(owner, repo, head, base, title, body) {
    const client = await this.getClient();
    
    return client.pulls.create({
      owner,
      repo,
      title,
      body,
      head,
      base,
    });
  }
}

export default new GitHubService();

Security Implementation

1. Firebase Auth Middleware

javascript
// src/middleware/auth.js
import admin from 'firebase-admin';

// Initialize once at startup
admin.initializeApp({
  credential: admin.credential.applicationDefault(),
});

export async function verifyFirebaseToken(req, res, next) {
  const authHeader = req.headers.authorization;
  
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing authorization header' });
  }
  
  const idToken = authHeader.split('Bearer ')[1];
  
  try {
    const decodedToken = await admin.auth().verifyIdToken(idToken);
    req.user = {
      uid: decodedToken.uid,
      email: decodedToken.email,
    };
    next();
  } catch (error) {
    return res.status(401).json({ error: 'Invalid token' });
  }
}

2. Role-Based Access Control

javascript
// src/middleware/rbac.js
import admin from 'firebase-admin';

const ROLE_HIERARCHY = {
  admin: 3,
  editor: 2,
  viewer: 1,
};

export function requireRole(minRole) {
  return async (req, res, next) => {
    const { uid } = req.user;
    
    // Fetch role from Firestore
    const userDoc = await admin.firestore()
      .collection('users')
      .doc(uid)
      .get();
    
    const userRole = userDoc.data()?.role || 'viewer';
    
    if (ROLE_HIERARCHY[userRole] < ROLE_HIERARCHY[minRole]) {
      return res.status(403).json({ 
        error: 'Insufficient permissions',
        required: minRole,
        current: userRole,
      });
    }
    
    req.user.role = userRole;
    next();
  };
}

3. Path Allowlist Enforcement

javascript
// src/services/pathValidator.js
const ALLOWED_PATHS = [
  /^docs\//,                           // All files under /docs
  /^docs\/.*\.md$/,                    // Only .md files
];

const DENIED_PATHS = [
  /^docs\/engineering\/secrets/,       // No secrets folder
  /\.env/,                             // No env files
  /node_modules/,                      // No node_modules
];

export function validatePath(path) {
  // Normalize path
  const normalizedPath = path.replace(/^\/+/, '').replace(/\/+/g, '/');
  
  // Check for path traversal attacks
  if (normalizedPath.includes('..')) {
    return { valid: false, reason: 'Path traversal not allowed' };
  }
  
  // Check denied paths first
  for (const pattern of DENIED_PATHS) {
    if (pattern.test(normalizedPath)) {
      return { valid: false, reason: 'Path is in deny list' };
    }
  }
  
  // Check allowed paths
  const isAllowed = ALLOWED_PATHS.some(pattern => pattern.test(normalizedPath));
  
  if (!isAllowed) {
    return { valid: false, reason: 'Path not in allowlist' };
  }
  
  return { valid: true, normalizedPath };
}

4. SHA Conflict Detection

javascript
// src/services/conflictResolver.js
export async function checkConflict(github, owner, repo, path, providedSha, branch = 'main') {
  try {
    const currentFile = await github.getFile(owner, repo, path, branch);
    
    if (currentFile.sha !== providedSha) {
      return {
        hasConflict: true,
        currentSha: currentFile.sha,
        currentContent: currentFile.content,
      };
    }
    
    return { hasConflict: false };
  } catch (error) {
    if (error.status === 404) {
      // File doesn't exist, no conflict for new files
      return { hasConflict: false, isNew: true };
    }
    throw error;
  }
}

Scaling & Performance

Cloud Run Configuration

yaml
# cloudbuild.yaml or service.yaml
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
  name: docs-editor-api
spec:
  template:
    metadata:
      annotations:
        autoscaling.knative.dev/minScale: "0"      # Scale to zero when idle
        autoscaling.knative.dev/maxScale: "10"     # Max instances
        run.googleapis.com/cpu-throttling: "false" # Keep CPU during startup
    spec:
      containerConcurrency: 80                      # Requests per instance
      timeoutSeconds: 60
      containers:
        - image: gcr.io/PROJECT/docs-editor-api
          resources:
            limits:
              cpu: "1"
              memory: "512Mi"
          env:
            - name: NODE_ENV
              value: "production"

Performance Optimizations

OptimizationImplementation
Token cachingCache GitHub installation tokens (1hr TTL)
Connection poolingReuse HTTP agents for GitHub API
Response cachingCache file listings with short TTL (30s)
Lazy authDon't fetch user role until needed
CompressionEnable gzip for large file responses

Rate Limiting

javascript
// src/middleware/rateLimiter.js
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';

// Use Cloud Memorystore for Redis in production
export const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,                  // 100 requests per window
  message: { error: 'Too many requests, please try again later' },
  standardHeaders: true,
  legacyHeaders: false,
  // For Cloud Run with multiple instances:
  // store: new RedisStore({ ... }),
});

export const writeLimiter = rateLimit({
  windowMs: 60 * 1000,      // 1 minute
  max: 10,                   // 10 writes per minute
  message: { error: 'Write rate limit exceeded' },
});

LayerTechnologyReason
RuntimeNode.js 20 LTSFast cold starts, good for I/O
FrameworkExpress.jsSimple, well-documented
GitHub SDK@octokit/rest + @octokit/auth-appOfficial, well-maintained
Firebasefirebase-adminServer-side auth verification
ValidationzodType-safe request validation
LoggingpinoFast structured logging
TestingvitestFast, compatible with your frontend

Deployment Checklist

1. GitHub App Setup

  • [ ] Create GitHub App with required permissions
  • [ ] Generate and download private key
  • [ ] Install App on target repository
  • [ ] Note: App ID, Installation ID

2. Secret Manager

  • [ ] Store GITHUB_APP_ID
  • [ ] Store GITHUB_APP_PRIVATE_KEY
  • [ ] Store GITHUB_APP_INSTALLATION_ID
  • [ ] Store GITHUB_OWNER and GITHUB_REPO

3. Cloud Run Service

  • [ ] Create service with appropriate memory/CPU
  • [ ] Grant Secret Manager access to service account
  • [ ] Configure Firebase Admin SDK (auto via ADC)
  • [ ] Set up custom domain (optional)

4. Firestore Setup

  • [ ] Ensure users collection has role field
  • [ ] Add security rules for role management

5. Frontend Integration

  • [ ] Add editor route to admin portal
  • [ ] Implement Firebase ID token forwarding
  • [ ] Handle 409 conflict responses in UI

Cost Estimate (Monthly)

ResourceUsage AssumptionEst. Cost
Cloud Run10K requests, 1 vCPU~$5
Secret Manager6 secrets, 10K accesses~$0.50
Cloud Memorystore (optional)Basic tier~$30
GitHub APIFree for App auth$0
Total~$5-35

Alternative Considerations

Why Not Other Runtimes?

RuntimeProsCons
Node.jsFast cold starts, familiar, good Octokit
PythonGood for data processingSlower cold starts
GoFastest runtimeSteeper learning curve
DenoModern, secure by defaultSmaller ecosystem

Verdict: Node.js is the right choice for this use case.

Why Not Cloud Functions?

Cloud Run is preferred because:

  • Longer request timeout (up to 60 min vs 9 min)
  • More control over container environment
  • Better for streaming responses (live editing)
  • Easier to add WebSocket support later

Future Enhancements

  1. Real-time collaboration — Add WebSocket support for live editing
  2. Branch per session — Auto-create branches for draft edits
  3. Merge conflict UI — Three-way diff viewer in admin portal
  4. Webhook receiver — Sync external changes back to editor
  5. Image uploads — Support for images via GitHub LFS or Cloud Storage

References

Built with VitePress